diff --git a/packages/minimongo/common.js b/packages/minimongo/common.js new file mode 100644 index 00000000000..7441d4f16d2 --- /dev/null +++ b/packages/minimongo/common.js @@ -0,0 +1,1376 @@ +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 !== 'number') { + throw Error('$type needs a number'); + } + + 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); + + 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 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) === '$'; + + 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('.')) + ); + + const omitUnnecessaryFields = result => { + if (!result.dontIterate) { + delete result.dontIterate; + } + + if (result.arrayIndices && !result.arrayIndices.length) { + delete result.arrayIndices; + } + + return result; + }; + + // 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.concat(+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 [omitUnnecessaryFields({ + arrayIndices, + dontIterate: Array.isArray(doc) && Array.isArray(firstLevel), + value: 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 [omitUnnecessaryFields({arrayIndices, value: 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.concat(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); + }); + } +} diff --git a/packages/minimongo/cursor.js b/packages/minimongo/cursor.js new file mode 100644 index 00000000000..6655f1f3ea7 --- /dev/null +++ b/packages/minimongo/cursor.js @@ -0,0 +1,468 @@ +import LocalCollection from './local_collection.js'; + +// Cursor: a specification for a particular subset of documents, w/ a defined +// order, limit, and offset. creating a Cursor with LocalCollection.find(), +export default class Cursor { + // don't call this ctor directly. use LocalCollection.find(). + constructor(collection, selector, options = {}) { + this.collection = collection; + this.sorter = null; + this.matcher = new Minimongo.Matcher(selector); + + if (LocalCollection._selectorIsIdPerhapsAsObject(selector)) { + // stash for fast _id and { _id } + this._selectorId = selector._id || selector; + } else { + this._selectorId = undefined; + + if (this.matcher.hasGeoQuery() || options.sort) { + this.sorter = new Minimongo.Sorter( + options.sort || [], + {matcher: this.matcher} + ); + } + } + + this.skip = options.skip || 0; + this.limit = options.limit; + this.fields = options.fields; + + this._projectionFn = LocalCollection._compileProjection(this.fields || {}); + + this._transform = LocalCollection.wrapTransform(options.transform); + + // by default, queries register w/ Tracker when it is available. + if (typeof Tracker !== 'undefined') { + this.reactive = options.reactive === undefined ? true : options.reactive; + } + } + + /** + * @summary Returns the number of documents that match a query. + * @memberOf Mongo.Cursor + * @method count + * @instance + * @locus Anywhere + * @returns {Number} + */ + count() { + if (this.reactive) { + // allow the observe to be unordered + this._depend({added: true, removed: true}, true); + } + + return this._getRawObjects({ordered: true}).length; + } + + /** + * @summary Return all matching documents as an Array. + * @memberOf Mongo.Cursor + * @method fetch + * @instance + * @locus Anywhere + * @returns {Object[]} + */ + fetch() { + const result = []; + + this.forEach(doc => { + result.push(doc); + }); + + return result; + } + + /** + * @callback IterationCallback + * @param {Object} doc + * @param {Number} index + */ + /** + * @summary Call `callback` once for each matching document, sequentially and + * synchronously. + * @locus Anywhere + * @method forEach + * @instance + * @memberOf Mongo.Cursor + * @param {IterationCallback} callback Function to call. It will be called + * with three arguments: the document, a + * 0-based index, and cursor + * itself. + * @param {Any} [thisArg] An object which will be the value of `this` inside + * `callback`. + */ + forEach(callback, thisArg) { + if (this.reactive) { + this._depend({ + addedBefore: true, + removed: true, + changed: true, + movedBefore: true}); + } + + this._getRawObjects({ordered: true}).forEach((element, i) => { + // This doubles as a clone operation. + element = this._projectionFn(element); + + if (this._transform) { + element = this._transform(element); + } + + callback.call(thisArg, element, i, this); + }); + } + + getTransform() { + return this._transform; + } + + /** + * @summary Map callback over all matching documents. Returns an Array. + * @locus Anywhere + * @method map + * @instance + * @memberOf Mongo.Cursor + * @param {IterationCallback} callback Function to call. It will be called + * with three arguments: the document, a + * 0-based index, and cursor + * itself. + * @param {Any} [thisArg] An object which will be the value of `this` inside + * `callback`. + */ + map(callback, thisArg) { + const result = []; + + this.forEach((doc, i) => { + result.push(callback.call(thisArg, doc, i, this)); + }); + + return result; + } + + // options to contain: + // * callbacks for observe(): + // - addedAt (document, atIndex) + // - added (document) + // - changedAt (newDocument, oldDocument, atIndex) + // - changed (newDocument, oldDocument) + // - removedAt (document, atIndex) + // - removed (document) + // - movedTo (document, oldIndex, newIndex) + // + // attributes available on returned query handle: + // * stop(): end updates + // * collection: the collection this query is querying + // + // iff x is a returned query handle, (x instanceof + // LocalCollection.ObserveHandle) is true + // + // initial results delivered through added callback + // XXX maybe callbacks should take a list of objects, to expose transactions? + // XXX maybe support field limiting (to limit what you're notified on) + + /** + * @summary Watch a query. Receive callbacks as the result set changes. + * @locus Anywhere + * @memberOf Mongo.Cursor + * @instance + * @param {Object} callbacks Functions to call to deliver the result set as it + * changes + */ + observe(options) { + return LocalCollection._observeFromObserveChanges(this, options); + } + + /** + * @summary Watch a query. Receive callbacks as the result set changes. Only + * the differences between the old and new documents are passed to + * the callbacks. + * @locus Anywhere + * @memberOf Mongo.Cursor + * @instance + * @param {Object} callbacks Functions to call to deliver the result set as it + * changes + */ + observeChanges(options) { + const ordered = LocalCollection._observeChangesCallbacksAreOrdered(options); + + // there are several places that assume you aren't combining skip/limit with + // unordered observe. eg, update's EJSON.clone, and the "there are several" + // comment in _modifyAndNotify + // XXX allow skip/limit with unordered observe + if (!options._allow_unordered && !ordered && (this.skip || this.limit)) { + throw new Error( + 'must use ordered observe (ie, \'addedBefore\' instead of \'added\') ' + + 'with skip or limit' + ); + } + + if (this.fields && (this.fields._id === 0 || this.fields._id === false)) { + throw Error('You may not observe a cursor with {fields: {_id: 0}}'); + } + + const distances = ( + this.matcher.hasGeoQuery() && + ordered && + new LocalCollection._IdMap + ); + + const query = { + cursor: this, + dirty: false, + distances, + matcher: this.matcher, // not fast pathed + ordered, + projectionFn: this._projectionFn, + resultsSnapshot: null, + sorter: ordered && this.sorter + }; + + let qid; + + // Non-reactive queries call added[Before] and then never call anything + // else. + if (this.reactive) { + qid = this.collection.next_qid++; + this.collection.queries[qid] = query; + } + + query.results = this._getRawObjects({ordered, distances: query.distances}); + + if (this.collection.paused) { + query.resultsSnapshot = ordered ? [] : new LocalCollection._IdMap; + } + + // wrap callbacks we were passed. callbacks only fire when not paused and + // are never undefined + // Filters out blacklisted fields according to cursor's projection. + // XXX wrong place for this? + + // furthermore, callbacks enqueue until the operation we're working on is + // done. + const wrapCallback = fn => { + if (!fn) { + return () => {}; + } + + const self = this; + return function(/* args*/) { + if (self.collection.paused) { + return; + } + + const args = arguments; + + self.collection._observeQueue.queueTask(() => { + fn.apply(this, args); + }); + }; + }; + + query.added = wrapCallback(options.added); + query.changed = wrapCallback(options.changed); + query.removed = wrapCallback(options.removed); + + if (ordered) { + query.addedBefore = wrapCallback(options.addedBefore); + query.movedBefore = wrapCallback(options.movedBefore); + } + + if (!options._suppress_initial && !this.collection.paused) { + const results = ordered ? query.results : query.results._map; + + Object.keys(results).forEach(key => { + const doc = results[key]; + const fields = EJSON.clone(doc); + + delete fields._id; + + if (ordered) { + query.addedBefore(doc._id, this._projectionFn(fields), null); + } + + query.added(doc._id, this._projectionFn(fields)); + }); + } + + const handle = Object.assign(new LocalCollection.ObserveHandle, { + collection: this.collection, + stop: () => { + if (this.reactive) { + delete this.collection.queries[qid]; + } + } + }); + + if (this.reactive && Tracker.active) { + // XXX in many cases, the same observe will be recreated when + // the current autorun is rerun. we could save work by + // letting it linger across rerun and potentially get + // repurposed if the same observe is performed, using logic + // similar to that of Meteor.subscribe. + Tracker.onInvalidate(() => { + handle.stop(); + }); + } + + // run the observe callbacks resulting from the initial contents + // before we leave the observe. + this.collection._observeQueue.drain(); + + return handle; + } + + // Since we don't actually have a "nextObject" interface, there's really no + // reason to have a "rewind" interface. All it did was make multiple calls + // to fetch/map/forEach return nothing the second time. + // XXX COMPAT WITH 0.8.1 + rewind() {} + + // XXX Maybe we need a version of observe that just calls a callback if + // anything changed. + _depend(changers, _allow_unordered) { + if (Tracker.active) { + const dependency = new Tracker.Dependency; + const notify = dependency.changed.bind(dependency); + + dependency.depend(); + + const options = {_allow_unordered, _suppress_initial: true}; + + ['added', 'addedBefore', 'changed', 'movedBefore', 'removed'] + .forEach(fn => { + if (changers[fn]) { + options[fn] = notify; + } + }); + + // observeChanges will stop() when this computation is invalidated + this.observeChanges(options); + } + } + + _getCollectionName() { + return this.collection.name; + } + + // Returns a collection of matching objects, but doesn't deep copy them. + // + // If ordered is set, returns a sorted array, respecting sorter, skip, and + // limit properties of the query. if sorter is falsey, no sort -- you get the + // natural order. + // + // If ordered is not set, returns an object mapping from ID to doc (sorter, + // skip and limit should not be set). + // + // If ordered is set and this cursor is a $near geoquery, then this function + // will use an _IdMap to track each distance from the $near argument point in + // order to use it as a sort key. If an _IdMap is passed in the 'distances' + // argument, this function will clear it and use it for this purpose + // (otherwise it will just create its own _IdMap). The observeChanges + // implementation uses this to remember the distances after this function + // returns. + _getRawObjects(options = {}) { + // XXX use OrderedDict instead of array, and make IdMap and OrderedDict + // compatible + const results = options.ordered ? [] : new LocalCollection._IdMap; + + // fast path for single ID value + if (this._selectorId !== undefined) { + // If you have non-zero skip and ask for a single id, you get + // nothing. This is so it matches the behavior of the '{_id: foo}' + // path. + if (this.skip) { + return results; + } + + const selectedDoc = this.collection._docs.get(this._selectorId); + + if (selectedDoc) { + if (options.ordered) { + results.push(selectedDoc); + } else { + results.set(this._selectorId, selectedDoc); + } + } + + return results; + } + + // slow path for arbitrary selector, sort, skip, limit + + // in the observeChanges case, distances is actually part of the "query" + // (ie, live results set) object. in other cases, distances is only used + // inside this function. + let distances; + if (this.matcher.hasGeoQuery() && options.ordered) { + if (options.distances) { + distances = options.distances; + distances.clear(); + } else { + distances = new LocalCollection._IdMap(); + } + } + + this.collection._docs.forEach((doc, id) => { + const matchResult = this.matcher.documentMatches(doc); + + if (matchResult.result) { + if (options.ordered) { + results.push(doc); + + if (distances && matchResult.distance !== undefined) { + distances.set(id, matchResult.distance); + } + } else { + results.set(id, doc); + } + } + + // Fast path for limited unsorted queries. + // XXX 'length' check here seems wrong for ordered + return ( + !this.limit || + this.skip || + this.sorter || + results.length !== this.limit + ); + }); + + if (!options.ordered) { + return results; + } + + if (this.sorter) { + results.sort(this.sorter.getComparator({distances})); + } + + if (!this.limit && !this.skip) { + return results; + } + + return results.slice( + this.skip, + this.limit ? this.limit + this.skip : results.length + ); + } + + _publishCursor(subscription) { + // XXX minimongo should not depend on mongo-livedata! + if (!Package.mongo) { + throw new Error( + 'Can\'t publish from Minimongo without the `mongo` package.' + ); + } + + if (!this.collection.name) { + throw new Error( + 'Can\'t publish a cursor from a collection without a name.' + ); + } + + return Package.mongo.Mongo.Collection._publishCursor( + this, + subscription, + this.collection.name + ); + } +} diff --git a/packages/minimongo/diff.js b/packages/minimongo/diff.js deleted file mode 100644 index 68bfca9c0c8..00000000000 --- a/packages/minimongo/diff.js +++ /dev/null @@ -1,21 +0,0 @@ -// ordered: bool. -// old_results and new_results: collections of documents. -// if ordered, they are arrays. -// if unordered, they are IdMaps -LocalCollection._diffQueryChanges = function (ordered, oldResults, newResults, observer, options) { - return DiffSequence.diffQueryChanges(ordered, oldResults, newResults, observer, options); -}; - -LocalCollection._diffQueryUnorderedChanges = function (oldResults, newResults, observer, options) { - return DiffSequence.diffQueryUnorderedChanges(oldResults, newResults, observer, options); -}; - - -LocalCollection._diffQueryOrderedChanges = - function (oldResults, newResults, observer, options) { - return DiffSequence.diffQueryOrderedChanges(oldResults, newResults, observer, options); -}; - -LocalCollection._diffObjects = function (left, right, callbacks) { - return DiffSequence.diffObjects(left, right, callbacks); -}; diff --git a/packages/minimongo/helpers.js b/packages/minimongo/helpers.js deleted file mode 100644 index a21c1dc49c5..00000000000 --- a/packages/minimongo/helpers.js +++ /dev/null @@ -1,45 +0,0 @@ -// Like _.isArray, but doesn't regard polyfilled Uint8Arrays on old browsers as -// arrays. -// XXX maybe this should be EJSON.isArray -isArray = function (x) { - return _.isArray(x) && !EJSON.isBinary(x); -}; - -// XXX maybe this should be EJSON.isObject, though EJSON doesn't know about -// RegExp -// XXX note that _type(undefined) === 3!!!! -isPlainObject = LocalCollection._isPlainObject = function (x) { - return x && LocalCollection._f._type(x) === 3; -}; - -isIndexable = function (x) { - return isArray(x) || isPlainObject(x); -}; - -// 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. -isOperatorObject = function (valueSelector, inconsistentOK) { - if (!isPlainObject(valueSelector)) - return false; - - var theseAreOperators = undefined; - _.each(valueSelector, function (value, selKey) { - var thisIsOperator = selKey.substr(0, 1) === '$'; - 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 -}; - - -// string can be converted to integer -isNumericKey = function (s) { - return /^[0-9]+$/.test(s); -}; \ No newline at end of file diff --git a/packages/minimongo/id_map.js b/packages/minimongo/id_map.js deleted file mode 100644 index ad51ab1ba21..00000000000 --- a/packages/minimongo/id_map.js +++ /dev/null @@ -1,7 +0,0 @@ -LocalCollection._IdMap = function () { - var self = this; - IdMap.call(self, MongoID.idStringify, MongoID.idParse); -}; - -Meteor._inherits(LocalCollection._IdMap, IdMap); - diff --git a/packages/minimongo/local_collection.js b/packages/minimongo/local_collection.js new file mode 100644 index 00000000000..17a2ba5216c --- /dev/null +++ b/packages/minimongo/local_collection.js @@ -0,0 +1,1999 @@ +import Cursor from './cursor.js'; +import ObserveHandle from './observe_handle.js'; +import { + hasOwn, + isIndexable, + isNumericKey, + isOperatorObject, + populateDocumentWithQueryFields, + projectionDetails, +} from './common.js'; + +// XXX type checking on selectors (graceful error if malformed) + +// LocalCollection: a set of documents that supports queries and modifiers. +export default class LocalCollection { + constructor(name) { + this.name = name; + // _id -> document (also containing id) + this._docs = new LocalCollection._IdMap; + + this._observeQueue = new Meteor._SynchronousQueue(); + + this.next_qid = 1; // live query id generator + + // qid -> live query object. keys: + // ordered: bool. ordered queries have addedBefore/movedBefore callbacks. + // results: array (ordered) or object (unordered) of current results + // (aliased with this._docs!) + // resultsSnapshot: snapshot of results. null if not paused. + // cursor: Cursor object for the query. + // selector, sorter, (callbacks): functions + this.queries = {}; + + // null if not saving originals; an IdMap from id to original document value + // if saving originals. See comments before saveOriginals(). + this._savedOriginals = null; + + // True when observers are paused and we should not send callbacks. + this.paused = false; + } + + // options may include sort, skip, limit, reactive + // sort may be any of these forms: + // {a: 1, b: -1} + // [["a", "asc"], ["b", "desc"]] + // ["a", ["b", "desc"]] + // (in the first form you're beholden to key enumeration order in + // your javascript VM) + // + // reactive: if given, and false, don't register with Tracker (default + // is true) + // + // XXX possibly should support retrieving a subset of fields? and + // have it be a hint (ignored on the client, when not copying the + // doc?) + // + // XXX sort does not yet support subkeys ('a.b') .. fix that! + // XXX add one more sort form: "key" + // XXX tests + find(selector, options) { + // default syntax for everything is to omit the selector argument. + // but if selector is explicitly passed in as false or undefined, we + // want a selector that matches nothing. + if (arguments.length === 0) { + selector = {}; + } + + return new LocalCollection.Cursor(this, selector, options); + } + + findOne(selector, options = {}) { + if (arguments.length === 0) { + selector = {}; + } + + // NOTE: by setting limit 1 here, we end up using very inefficient + // code that recomputes the whole query on each update. The upside is + // that when you reactively depend on a findOne you only get + // invalidated when the found object changes, not any object in the + // collection. Most findOne will be by id, which has a fast path, so + // this might not be a big deal. In most cases, invalidation causes + // the called to re-query anyway, so this should be a net performance + // improvement. + options.limit = 1; + + return this.find(selector, options).fetch()[0]; + } + + // XXX possibly enforce that 'undefined' does not appear (we assume + // this in our handling of null and $exists) + insert(doc, callback) { + doc = EJSON.clone(doc); + + assertHasValidFieldNames(doc); + + // if you really want to use ObjectIDs, set this global. + // Mongo.Collection specifies its own ids and does not use this code. + if (!hasOwn.call(doc, '_id')) { + doc._id = LocalCollection._useOID ? new MongoID.ObjectID() : Random.id(); + } + + const id = doc._id; + + if (this._docs.has(id)) { + throw MinimongoError(`Duplicate _id '${id}'`); + } + + this._saveOriginal(id, undefined); + this._docs.set(id, doc); + + const queriesToRecompute = []; + + // trigger live queries that match + for (let qid in this.queries) { + const query = this.queries[qid]; + + if (query.dirty) { + continue; + } + + const matchResult = query.matcher.documentMatches(doc); + + if (matchResult.result) { + if (query.distances && matchResult.distance !== undefined) { + query.distances.set(id, matchResult.distance); + } + + if (query.cursor.skip || query.cursor.limit) { + queriesToRecompute.push(qid); + } else { + LocalCollection._insertInResults(query, doc); + } + } + } + + queriesToRecompute.forEach(qid => { + if (this.queries[qid]) { + this._recomputeResults(this.queries[qid]); + } + }); + + this._observeQueue.drain(); + + // Defer because the caller likely doesn't expect the callback to be run + // immediately. + if (callback) { + Meteor.defer(() => { + callback(null, id); + }); + } + + return id; + } + + // Pause the observers. No callbacks from observers will fire until + // 'resumeObservers' is called. + pauseObservers() { + // No-op if already paused. + if (this.paused) { + return; + } + + // Set the 'paused' flag such that new observer messages don't fire. + this.paused = true; + + // Take a snapshot of the query results for each query. + for (let qid in this.queries) { + const query = this.queries[qid]; + + query.resultsSnapshot = EJSON.clone(query.results); + } + } + + remove(selector, callback) { + // Easy special case: if we're not calling observeChanges callbacks and + // we're not saving originals and we got asked to remove everything, then + // just empty everything directly. + if (this.paused && !this._savedOriginals && EJSON.equals(selector, {})) { + const result = this._docs.size(); + + this._docs.clear(); + + for (let qid in this.queries) { + const query = this.queries[qid]; + + if (query.ordered) { + query.results = []; + } else { + query.results.clear(); + } + } + + if (callback) { + Meteor.defer(() => { + callback(null, result); + }); + } + + return result; + } + + const matcher = new Minimongo.Matcher(selector); + const remove = []; + + this._eachPossiblyMatchingDoc(selector, (doc, id) => { + if (matcher.documentMatches(doc).result) { + remove.push(id); + } + }); + + const queriesToRecompute = []; + const queryRemove = []; + + for (let i = 0; i < remove.length; i++) { + const removeId = remove[i]; + const removeDoc = this._docs.get(removeId); + + for (let qid in this.queries) { + const query = this.queries[qid]; + + if (query.dirty) { + return; + } + + if (query.matcher.documentMatches(removeDoc).result) { + if (query.cursor.skip || query.cursor.limit) { + queriesToRecompute.push(qid); + } else { + queryRemove.push({qid, doc: removeDoc}); + } + } + } + + this._saveOriginal(removeId, removeDoc); + this._docs.remove(removeId); + } + + // run live query callbacks _after_ we've removed the documents. + queryRemove.forEach(remove => { + const query = this.queries[remove.qid]; + + if (query) { + query.distances && query.distances.remove(remove.doc._id); + LocalCollection._removeFromResults(query, remove.doc); + } + }); + + queriesToRecompute.forEach(qid => { + const query = this.queries[qid]; + + if (query) { + this._recomputeResults(query); + } + }); + + this._observeQueue.drain(); + + const result = remove.length; + + if (callback) { + Meteor.defer(() => { + callback(null, result); + }); + } + + return result; + } + + // Resume the observers. Observers immediately receive change + // notifications to bring them to the current state of the + // database. Note that this is not just replaying all the changes that + // happened during the pause, it is a smarter 'coalesced' diff. + resumeObservers() { + // No-op if not paused. + if (!this.paused) { + return; + } + + // Unset the 'paused' flag. Make sure to do this first, otherwise + // observer methods won't actually fire when we trigger them. + this.paused = false; + + for (let qid in this.queries) { + const query = this.queries[qid]; + + if (query.dirty) { + query.dirty = false; + + // re-compute results will perform `LocalCollection._diffQueryChanges` + // automatically. + this._recomputeResults(query, query.resultsSnapshot); + } else { + // Diff the current results against the snapshot and send to observers. + // pass the query object for its observer callbacks. + LocalCollection._diffQueryChanges( + query.ordered, + query.resultsSnapshot, + query.results, + query, + {projectionFn: query.projectionFn} + ); + } + + query.resultsSnapshot = null; + } + + this._observeQueue.drain(); + } + + retrieveOriginals() { + if (!this._savedOriginals) { + throw new Error('Called retrieveOriginals without saveOriginals'); + } + + const originals = this._savedOriginals; + + this._savedOriginals = null; + + return originals; + } + + // To track what documents are affected by a piece of code, call + // saveOriginals() before it and retrieveOriginals() after it. + // retrieveOriginals returns an object whose keys are the ids of the documents + // that were affected since the call to saveOriginals(), and the values are + // equal to the document's contents at the time of saveOriginals. (In the case + // of an inserted document, undefined is the value.) You must alternate + // between calls to saveOriginals() and retrieveOriginals(). + saveOriginals() { + if (this._savedOriginals) { + throw new Error('Called saveOriginals twice without retrieveOriginals'); + } + + this._savedOriginals = new LocalCollection._IdMap; + } + + // XXX atomicity: if multi is true, and one modification fails, do + // we rollback the whole operation, or what? + update(selector, mod, options, callback) { + if (! callback && options instanceof Function) { + callback = options; + options = null; + } + + if (!options) { + options = {}; + } + + const matcher = new Minimongo.Matcher(selector, true); + + // Save the original results of any query that we might need to + // _recomputeResults on, because _modifyAndNotify will mutate the objects in + // it. (We don't need to save the original results of paused queries because + // they already have a resultsSnapshot and we won't be diffing in + // _recomputeResults.) + const qidToOriginalResults = {}; + + // We should only clone each document once, even if it appears in multiple + // queries + const docMap = new LocalCollection._IdMap; + const idsMatched = LocalCollection._idsMatchedBySelector(selector); + + for (let qid in this.queries) { + const query = this.queries[qid]; + + if ((query.cursor.skip || query.cursor.limit) && ! this.paused) { + // Catch the case of a reactive `count()` on a cursor with skip + // or limit, which registers an unordered observe. This is a + // pretty rare case, so we just clone the entire result set with + // no optimizations for documents that appear in these result + // sets and other queries. + if (query.results instanceof LocalCollection._IdMap) { + qidToOriginalResults[qid] = query.results.clone(); + return; + } + + if (!(query.results instanceof Array)) { + throw new Error('Assertion failed: query.results not an array'); + } + + // Clones a document to be stored in `qidToOriginalResults` + // because it may be modified before the new and old result sets + // are diffed. But if we know exactly which document IDs we're + // going to modify, then we only need to clone those. + const memoizedCloneIfNeeded = doc => { + if (docMap.has(doc._id)) { + return docMap.get(doc._id); + } + + const docToMemoize = ( + idsMatched && + !idsMatched.some(id => EJSON.equals(id, doc._id)) + ) ? doc : EJSON.clone(doc); + + docMap.set(doc._id, docToMemoize); + + return docToMemoize; + }; + + qidToOriginalResults[qid] = query.results.map(memoizedCloneIfNeeded); + } + } + + const recomputeQids = {}; + + let updateCount = 0; + + this._eachPossiblyMatchingDoc(selector, (doc, id) => { + const queryResult = matcher.documentMatches(doc); + + if (queryResult.result) { + // XXX Should we save the original even if mod ends up being a no-op? + this._saveOriginal(id, doc); + this._modifyAndNotify( + doc, + mod, + recomputeQids, + queryResult.arrayIndices + ); + + ++updateCount; + + if (!options.multi) { + return false; // break + } + } + + return true; + }); + + Object.keys(recomputeQids).forEach(qid => { + const query = this.queries[qid]; + + if (query) { + this._recomputeResults(query, qidToOriginalResults[qid]); + } + }); + + this._observeQueue.drain(); + + // If we are doing an upsert, and we didn't modify any documents yet, then + // it's time to do an insert. Figure out what document we are inserting, and + // generate an id for it. + let insertedId; + if (updateCount === 0 && options.upsert) { + const doc = LocalCollection._createUpsertDocument(selector, mod); + if (! doc._id && options.insertedId) { + doc._id = options.insertedId; + } + + insertedId = this.insert(doc); + updateCount = 1; + } + + // Return the number of affected documents, or in the upsert case, an object + // containing the number of affected docs and the id of the doc that was + // inserted, if any. + let result; + if (options._returnObject) { + result = {numberAffected: updateCount}; + + if (insertedId !== undefined) { + result.insertedId = insertedId; + } + } else { + result = updateCount; + } + + if (callback) { + Meteor.defer(() => { + callback(null, result); + }); + } + + return result; + } + + // A convenience wrapper on update. LocalCollection.upsert(sel, mod) is + // equivalent to LocalCollection.update(sel, mod, {upsert: true, + // _returnObject: true}). + upsert(selector, mod, options, callback) { + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + return this.update( + selector, + mod, + Object.assign({}, options, {upsert: true, _returnObject: true}), + callback + ); + } + + // Iterates over a subset of documents that could match selector; calls + // fn(doc, id) on each of them. Specifically, if selector specifies + // specific _id's, it only looks at those. doc is *not* cloned: it is the + // same object that is in _docs. + _eachPossiblyMatchingDoc(selector, fn) { + const specificIds = LocalCollection._idsMatchedBySelector(selector); + + if (specificIds) { + specificIds.some(id => { + const doc = this._docs.get(id); + + if (doc) { + return fn(doc, id) === false; + } + }); + } else { + this._docs.forEach(fn); + } + } + + _modifyAndNotify(doc, mod, recomputeQids, arrayIndices) { + const matched_before = {}; + + for (let qid in this.queries) { + const query = this.queries[qid]; + + if (query.dirty) { + continue; + } + + if (query.ordered) { + matched_before[qid] = query.matcher.documentMatches(doc).result; + } else { + // Because we don't support skip or limit (yet) in unordered queries, we + // can just do a direct lookup. + matched_before[qid] = query.results.has(doc._id); + } + } + + const old_doc = EJSON.clone(doc); + + LocalCollection._modify(doc, mod, {arrayIndices}); + + for (let qid in this.queries) { + const query = this.queries[qid]; + + if (query.dirty) { + continue; + } + + const afterMatch = query.matcher.documentMatches(doc); + const after = afterMatch.result; + const before = matched_before[qid]; + + if (after && query.distances && afterMatch.distance !== undefined) { + query.distances.set(doc._id, afterMatch.distance); + } + + if (query.cursor.skip || query.cursor.limit) { + // We need to recompute any query where the doc may have been in the + // cursor's window either before or after the update. (Note that if skip + // or limit is set, "before" and "after" being true do not necessarily + // mean that the document is in the cursor's output after skip/limit is + // applied... but if they are false, then the document definitely is NOT + // in the output. So it's safe to skip recompute if neither before or + // after are true.) + if (before || after) { + recomputeQids[qid] = true; + } + } else if (before && !after) { + LocalCollection._removeFromResults(query, doc); + } else if (!before && after) { + LocalCollection._insertInResults(query, doc); + } else if (before && after) { + LocalCollection._updateInResults(query, doc, old_doc); + } + } + } + + // Recomputes the results of a query and runs observe callbacks for the + // difference between the previous results and the current results (unless + // paused). Used for skip/limit queries. + // + // When this is used by insert or remove, it can just use query.results for + // the old results (and there's no need to pass in oldResults), because these + // operations don't mutate the documents in the collection. Update needs to + // pass in an oldResults which was deep-copied before the modifier was + // applied. + // + // oldResults is guaranteed to be ignored if the query is not paused. + _recomputeResults(query, oldResults) { + if (this.paused) { + // There's no reason to recompute the results now as we're still paused. + // By flagging the query as "dirty", the recompute will be performed + // when resumeObservers is called. + query.dirty = true; + return; + } + + if (!this.paused && !oldResults) { + oldResults = query.results; + } + + if (query.distances) { + query.distances.clear(); + } + + query.results = query.cursor._getRawObjects({ + distances: query.distances, + ordered: query.ordered + }); + + if (!this.paused) { + LocalCollection._diffQueryChanges( + query.ordered, + oldResults, + query.results, + query, + {projectionFn: query.projectionFn} + ); + } + } + + _saveOriginal(id, doc) { + // Are we even trying to save originals? + if (!this._savedOriginals) { + return; + } + + // Have we previously mutated the original (and so 'doc' is not actually + // original)? (Note the 'has' check rather than truth: we store undefined + // here for inserted docs!) + if (this._savedOriginals.has(id)) { + return; + } + + this._savedOriginals.set(id, EJSON.clone(doc)); + } +} + +LocalCollection.Cursor = Cursor; + +LocalCollection.ObserveHandle = ObserveHandle; + +// XXX maybe move these into another ObserveHelpers package or something + +// _CachingChangeObserver is an object which receives observeChanges callbacks +// and keeps a cache of the current cursor state up to date in this.docs. Users +// of this class should read the docs field but not modify it. You should pass +// the "applyChange" field as the callbacks to the underlying observeChanges +// call. Optionally, you can specify your own observeChanges callbacks which are +// invoked immediately before the docs field is updated; this object is made +// available as `this` to those callbacks. +LocalCollection._CachingChangeObserver = class _CachingChangeObserver { + constructor(options = {}) { + const orderedFromCallbacks = ( + options.callbacks && + LocalCollection._observeChangesCallbacksAreOrdered(options.callbacks) + ); + + if (hasOwn.call(options, 'ordered')) { + this.ordered = options.ordered; + + if (options.callbacks && options.ordered !== orderedFromCallbacks) { + throw Error('ordered option doesn\'t match callbacks'); + } + } else if (options.callbacks) { + this.ordered = orderedFromCallbacks; + } else { + throw Error('must provide ordered or callbacks'); + } + + const callbacks = options.callbacks || {}; + + if (this.ordered) { + this.docs = new OrderedDict(MongoID.idStringify); + this.applyChange = { + addedBefore: (id, fields, before) => { + const doc = EJSON.clone(fields); + + doc._id = id; + + if (callbacks.addedBefore) { + callbacks.addedBefore.call(this, id, fields, before); + } + + // This line triggers if we provide added with movedBefore. + if (callbacks.added) { + callbacks.added.call(this, id, fields); + } + + // XXX could `before` be a falsy ID? Technically + // idStringify seems to allow for them -- though + // OrderedDict won't call stringify on a falsy arg. + this.docs.putBefore(id, doc, before || null); + }, + movedBefore: (id, before) => { + const doc = this.docs.get(id); + + if (callbacks.movedBefore) { + callbacks.movedBefore.call(this, id, before); + } + + this.docs.moveBefore(id, before || null); + }, + }; + } else { + this.docs = new LocalCollection._IdMap; + this.applyChange = { + added: (id, fields) => { + const doc = EJSON.clone(fields); + + if (callbacks.added) { + callbacks.added.call(this, id, fields); + } + + doc._id = id; + + this.docs.set(id, doc); + }, + }; + } + + // The methods in _IdMap and OrderedDict used by these callbacks are + // identical. + this.applyChange.changed = (id, fields) => { + const doc = this.docs.get(id); + + if (!doc) { + throw new Error(`Unknown id for changed: ${id}`); + } + + if (callbacks.changed) { + callbacks.changed.call(this, id, EJSON.clone(fields)); + } + + DiffSequence.applyChanges(doc, fields); + }; + + this.applyChange.removed = id => { + if (callbacks.removed) { + callbacks.removed.call(this, id); + } + + this.docs.remove(id); + }; + } +}; + +LocalCollection._IdMap = class _IdMap extends IdMap { + constructor() { + super(MongoID.idStringify, MongoID.idParse); + } +}; + +// Wrap a transform function to return objects that have the _id field +// of the untransformed document. This ensures that subsystems such as +// the observe-sequence package that call `observe` can keep track of +// the documents identities. +// +// - Require that it returns objects +// - If the return value has an _id field, verify that it matches the +// original _id field +// - If the return value doesn't have an _id field, add it back. +LocalCollection.wrapTransform = transform => { + if (!transform) { + return null; + } + + // No need to doubly-wrap transforms. + if (transform.__wrappedTransform__) { + return transform; + } + + const wrapped = doc => { + if (!hasOwn.call(doc, '_id')) { + // XXX do we ever have a transform on the oplog's collection? because that + // collection has no _id. + throw new Error('can only transform documents with _id'); + } + + const id = doc._id; + + // XXX consider making tracker a weak dependency and checking + // Package.tracker here + const transformed = Tracker.nonreactive(() => transform(doc)); + + if (!LocalCollection._isPlainObject(transformed)) { + throw new Error('transform must return object'); + } + + if (hasOwn.call(transformed, '_id')) { + if (!EJSON.equals(transformed._id, id)) { + throw new Error('transformed document can\'t have different _id'); + } + } else { + transformed._id = id; + } + + return transformed; + }; + + wrapped.__wrappedTransform__ = true; + + return wrapped; +}; + +// XXX the sorted-query logic below is laughably inefficient. we'll +// need to come up with a better datastructure for this. +// +// XXX the logic for observing with a skip or a limit is even more +// laughably inefficient. we recompute the whole results every time! + +// This binary search puts a value between any equal values, and the first +// lesser value. +LocalCollection._binarySearch = (cmp, array, value) => { + let first = 0; + let range = array.length; + + while (range > 0) { + const halfRange = Math.floor(range / 2); + + if (cmp(value, array[first + halfRange]) >= 0) { + first += halfRange + 1; + range -= halfRange + 1; + } else { + range = halfRange; + } + } + + return first; +}; + +LocalCollection._checkSupportedProjection = fields => { + if (fields !== Object(fields) || Array.isArray(fields)) { + throw MinimongoError('fields option must be an object'); + } + + Object.keys(fields).forEach(keyPath => { + if (keyPath.split('.').includes('$')) { + throw MinimongoError( + 'Minimongo doesn\'t support $ operator in projections yet.' + ); + } + + const value = fields[keyPath]; + + if (typeof value === 'object' && + ['$elemMatch', '$meta', '$slice'].some(key => + hasOwn.call(value, key) + )) { + throw MinimongoError( + 'Minimongo doesn\'t support operators in projections yet.' + ); + } + + if (![1, 0, true, false].includes(value)) { + throw MinimongoError( + 'Projection values should be one of 1, 0, true, or false' + ); + } + }); +}; + +// Knows how to compile a fields projection to a predicate function. +// @returns - Function: a closure that filters out an object according to the +// fields projection rules: +// @param obj - Object: MongoDB-styled document +// @returns - Object: a document with the fields filtered out +// according to projection rules. Doesn't retain subfields +// of passed argument. +LocalCollection._compileProjection = fields => { + LocalCollection._checkSupportedProjection(fields); + + const _idProjection = fields._id === undefined ? true : fields._id; + const details = projectionDetails(fields); + + // returns transformed doc according to ruleTree + const transform = (doc, ruleTree) => { + // Special case for "sets" + if (Array.isArray(doc)) { + return doc.map(subdoc => transform(subdoc, ruleTree)); + } + + const result = details.including ? {} : EJSON.clone(doc); + + Object.keys(ruleTree).forEach(key => { + if (!hasOwn.call(doc, key)) { + return; + } + + const rule = ruleTree[key]; + + if (rule === Object(rule)) { + // For sub-objects/subsets we branch + if (doc[key] === Object(doc[key])) { + result[key] = transform(doc[key], rule); + } + } else if (details.including) { + // Otherwise we don't even touch this subfield + result[key] = EJSON.clone(doc[key]); + } else { + delete result[key]; + } + }); + + return result; + }; + + return doc => { + const result = transform(doc, details.tree); + + if (_idProjection && hasOwn.call(doc, '_id')) { + result._id = doc._id; + } + + if (!_idProjection && hasOwn.call(result, '_id')) { + delete result._id; + } + + return result; + }; +}; + +// Calculates the document to insert in case we're doing an upsert and the +// selector does not match any elements +LocalCollection._createUpsertDocument = (selector, modifier) => { + const selectorDocument = populateDocumentWithQueryFields(selector); + const isModify = LocalCollection._isModificationMod(modifier); + + const newDoc = {}; + + if (selectorDocument._id) { + newDoc._id = selectorDocument._id; + delete selectorDocument._id; + } + + // This double _modify call is made to help with nested properties (see issue + // #8631). We do this even if it's a replacement for validation purposes (e.g. + // ambiguous id's) + LocalCollection._modify(newDoc, {$set: selectorDocument}); + LocalCollection._modify(newDoc, modifier, {isInsert: true}); + + if (isModify) { + return newDoc; + } + + // Replacement can take _id from query document + const replacement = Object.assign({}, modifier); + if (newDoc._id) { + replacement._id = newDoc._id; + } + + return replacement; +}; + +LocalCollection._diffObjects = (left, right, callbacks) => { + return DiffSequence.diffObjects(left, right, callbacks); +}; + +// ordered: bool. +// old_results and new_results: collections of documents. +// if ordered, they are arrays. +// if unordered, they are IdMaps +LocalCollection._diffQueryChanges = (ordered, oldResults, newResults, observer, options) => + DiffSequence.diffQueryChanges(ordered, oldResults, newResults, observer, options) +; + +LocalCollection._diffQueryOrderedChanges = (oldResults, newResults, observer, options) => + DiffSequence.diffQueryOrderedChanges(oldResults, newResults, observer, options) +; + +LocalCollection._diffQueryUnorderedChanges = (oldResults, newResults, observer, options) => + DiffSequence.diffQueryUnorderedChanges(oldResults, newResults, observer, options) +; + +LocalCollection._findInOrderedResults = (query, doc) => { + if (!query.ordered) { + throw new Error('Can\'t call _findInOrderedResults on unordered query'); + } + + for (let i = 0; i < query.results.length; i++) { + if (query.results[i] === doc) { + return i; + } + } + + throw Error('object missing from query'); +}; + +// If this is a selector which explicitly constrains the match by ID to a finite +// number of documents, returns a list of their IDs. Otherwise returns +// null. Note that the selector may have other restrictions so it may not even +// match those document! We care about $in and $and since those are generated +// access-controlled update and remove. +LocalCollection._idsMatchedBySelector = selector => { + // Is the selector just an ID? + if (LocalCollection._selectorIsId(selector)) { + return [selector]; + } + + if (!selector) { + return null; + } + + // Do we have an _id clause? + if (hasOwn.call(selector, '_id')) { + // Is the _id clause just an ID? + if (LocalCollection._selectorIsId(selector._id)) { + return [selector._id]; + } + + // Is the _id clause {_id: {$in: ["x", "y", "z"]}}? + if (selector._id + && Array.isArray(selector._id.$in) + && selector._id.$in.length + && selector._id.$in.every(LocalCollection._selectorIsId)) { + return selector._id.$in; + } + + return null; + } + + // If this is a top-level $and, and any of the clauses constrain their + // documents, then the whole selector is constrained by any one clause's + // constraint. (Well, by their intersection, but that seems unlikely.) + if (Array.isArray(selector.$and)) { + for (let i = 0; i < selector.$and.length; ++i) { + const subIds = LocalCollection._idsMatchedBySelector(selector.$and[i]); + + if (subIds) { + return subIds; + } + } + } + + return null; +}; + +LocalCollection._insertInResults = (query, doc) => { + const fields = EJSON.clone(doc); + + delete fields._id; + + if (query.ordered) { + if (!query.sorter) { + query.addedBefore(doc._id, query.projectionFn(fields), null); + query.results.push(doc); + } else { + const i = LocalCollection._insertInSortedList( + query.sorter.getComparator({distances: query.distances}), + query.results, + doc + ); + + let next = query.results[i + 1]; + if (next) { + next = next._id; + } else { + next = null; + } + + query.addedBefore(doc._id, query.projectionFn(fields), next); + } + + query.added(doc._id, query.projectionFn(fields)); + } else { + query.added(doc._id, query.projectionFn(fields)); + query.results.set(doc._id, doc); + } +}; + +LocalCollection._insertInSortedList = (cmp, array, value) => { + if (array.length === 0) { + array.push(value); + return 0; + } + + const i = LocalCollection._binarySearch(cmp, array, value); + + array.splice(i, 0, value); + + return i; +}; + +LocalCollection._isModificationMod = mod => { + let isModify = false; + let isReplace = false; + + for (const key in mod) { + if (key.substr(0, 1) === '$') { + isModify = true; + } else { + isReplace = true; + } + } + + if (isModify && isReplace) { + throw new Error( + 'Update parameter cannot have both modifier and non-modifier fields.' + ); + } + + return isModify; +}; + +// XXX maybe this should be EJSON.isObject, though EJSON doesn't know about +// RegExp +// XXX note that _type(undefined) === 3!!!! +LocalCollection._isPlainObject = x => { + return x && LocalCollection._f._type(x) === 3; +}; + +// XXX need a strategy for passing the binding of $ into this +// function, from the compiled selector +// +// maybe just {key.up.to.just.before.dollarsign: array_index} +// +// XXX atomicity: if one modification fails, do we roll back the whole +// change? +// +// options: +// - isInsert is set when _modify is being called to compute the document to +// insert as part of an upsert operation. We use this primarily to figure +// out when to set the fields in $setOnInsert, if present. +LocalCollection._modify = (doc, modifier, options = {}) => { + if (!LocalCollection._isPlainObject(modifier)) { + throw MinimongoError('Modifier must be an object'); + } + + // Make sure the caller can't mutate our data structures. + modifier = EJSON.clone(modifier); + + const isModifier = isOperatorObject(modifier); + const newDoc = isModifier ? EJSON.clone(doc) : modifier; + + if (isModifier) { + // apply modifiers to the doc. + Object.keys(modifier).forEach(operator => { + // Treat $setOnInsert as $set if this is an insert. + const setOnInsert = options.isInsert && operator === '$setOnInsert'; + const modFunc = MODIFIERS[setOnInsert ? '$set' : operator]; + const operand = modifier[operator]; + + if (!modFunc) { + throw MinimongoError(`Invalid modifier specified ${operator}`); + } + + Object.keys(operand).forEach(keypath => { + const arg = operand[keypath]; + + if (keypath === '') { + throw MinimongoError('An empty update path is not valid.'); + } + + const keyparts = keypath.split('.'); + + if (!keyparts.every(Boolean)) { + throw MinimongoError( + `The update path '${keypath}' contains an empty field name, ` + + 'which is not allowed.' + ); + } + + const target = findModTarget(newDoc, keyparts, { + arrayIndices: options.arrayIndices, + forbidArray: operator === '$rename', + noCreate: NO_CREATE_MODIFIERS[operator] + }); + + modFunc(target, keyparts.pop(), arg, keypath, newDoc); + }); + }); + + if (doc._id && !EJSON.equals(doc._id, newDoc._id)) { + throw MinimongoError( + `After applying the update to the document {_id: "${doc._id}", ...},` + + ' the (immutable) field \'_id\' was found to have been altered to ' + + `_id: "${newDoc._id}"` + ); + } + } else { + if (doc._id && modifier._id && !EJSON.equals(doc._id, modifier._id)) { + throw MinimongoError( + `The _id field cannot be changed from {_id: "${doc._id}"} to ` + + `{_id: "${modifier._id}"}` + ); + } + + // replace the whole document + assertHasValidFieldNames(modifier); + } + + // move new document into place. + Object.keys(doc).forEach(key => { + // Note: this used to be for (var key in doc) however, this does not + // work right in Opera. Deleting from a doc while iterating over it + // would sometimes cause opera to skip some keys. + if (key !== '_id') { + delete doc[key]; + } + }); + + Object.keys(newDoc).forEach(key => { + doc[key] = newDoc[key]; + }); +}; + +LocalCollection._observeFromObserveChanges = (cursor, observeCallbacks) => { + const transform = cursor.getTransform() || (doc => doc); + let suppressed = !!observeCallbacks._suppress_initial; + + let observeChangesCallbacks; + if (LocalCollection._observeCallbacksAreOrdered(observeCallbacks)) { + // The "_no_indices" option sets all index arguments to -1 and skips the + // linear scans required to generate them. This lets observers that don't + // need absolute indices benefit from the other features of this API -- + // relative order, transforms, and applyChanges -- without the speed hit. + const indices = !observeCallbacks._no_indices; + + observeChangesCallbacks = { + addedBefore(id, fields, before) { + if (suppressed || !(observeCallbacks.addedAt || observeCallbacks.added)) { + return; + } + + const doc = transform(Object.assign(fields, {_id: id})); + + if (observeCallbacks.addedAt) { + observeCallbacks.addedAt( + doc, + indices + ? before + ? this.docs.indexOf(before) + : this.docs.size() + : -1, + before + ); + } else { + observeCallbacks.added(doc); + } + }, + changed(id, fields) { + if (!(observeCallbacks.changedAt || observeCallbacks.changed)) { + return; + } + + let doc = EJSON.clone(this.docs.get(id)); + if (!doc) { + throw new Error(`Unknown id for changed: ${id}`); + } + + const oldDoc = transform(EJSON.clone(doc)); + + DiffSequence.applyChanges(doc, fields); + + if (observeCallbacks.changedAt) { + observeCallbacks.changedAt( + transform(doc), + oldDoc, + indices ? this.docs.indexOf(id) : -1 + ); + } else { + observeCallbacks.changed(transform(doc), oldDoc); + } + }, + movedBefore(id, before) { + if (!observeCallbacks.movedTo) { + return; + } + + const from = indices ? this.docs.indexOf(id) : -1; + let to = indices + ? before + ? this.docs.indexOf(before) + : this.docs.size() + : -1; + + // When not moving backwards, adjust for the fact that removing the + // document slides everything back one slot. + if (to > from) { + --to; + } + + observeCallbacks.movedTo( + transform(EJSON.clone(this.docs.get(id))), + from, + to, + before || null + ); + }, + removed(id) { + if (!(observeCallbacks.removedAt || observeCallbacks.removed)) { + return; + } + + // technically maybe there should be an EJSON.clone here, but it's about + // to be removed from this.docs! + const doc = transform(this.docs.get(id)); + + if (observeCallbacks.removedAt) { + observeCallbacks.removedAt(doc, indices ? this.docs.indexOf(id) : -1); + } else { + observeCallbacks.removed(doc); + } + }, + }; + } else { + observeChangesCallbacks = { + added(id, fields) { + if (!suppressed && observeCallbacks.added) { + observeCallbacks.added(transform(Object.assign(fields, {_id: id}))); + } + }, + changed(id, fields) { + if (observeCallbacks.changed) { + const oldDoc = this.docs.get(id); + const doc = EJSON.clone(oldDoc); + + DiffSequence.applyChanges(doc, fields); + + observeCallbacks.changed( + transform(doc), + transform(EJSON.clone(oldDoc)) + ); + } + }, + removed(id) { + if (observeCallbacks.removed) { + observeCallbacks.removed(transform(this.docs.get(id))); + } + }, + }; + } + + const changeObserver = new LocalCollection._CachingChangeObserver({ + callbacks: observeChangesCallbacks + }); + + const handle = cursor.observeChanges(changeObserver.applyChange); + + suppressed = false; + + return handle; +}; + +LocalCollection._observeCallbacksAreOrdered = callbacks => { + if (callbacks.added && callbacks.addedAt) { + throw new Error('Please specify only one of added() and addedAt()'); + } + + if (callbacks.changed && callbacks.changedAt) { + throw new Error('Please specify only one of changed() and changedAt()'); + } + + if (callbacks.removed && callbacks.removedAt) { + throw new Error('Please specify only one of removed() and removedAt()'); + } + + return !!( + callbacks.addedAt || + callbacks.changedAt || + callbacks.movedTo || + callbacks.removedAt + ); +}; + +LocalCollection._observeChangesCallbacksAreOrdered = callbacks => { + if (callbacks.added && callbacks.addedBefore) { + throw new Error('Please specify only one of added() and addedBefore()'); + } + + return !!(callbacks.addedBefore || callbacks.movedBefore); +}; + +LocalCollection._removeFromResults = (query, doc) => { + if (query.ordered) { + const i = LocalCollection._findInOrderedResults(query, doc); + + query.removed(doc._id); + query.results.splice(i, 1); + } else { + const id = doc._id; // in case callback mutates doc + + query.removed(doc._id); + query.results.remove(id); + } +}; + +// Is this selector just shorthand for lookup by _id? +LocalCollection._selectorIsId = selector => + typeof selector === 'number' || + typeof selector === 'string' || + selector instanceof MongoID.ObjectID +; + +// Is the selector just lookup by _id (shorthand or not)? +LocalCollection._selectorIsIdPerhapsAsObject = selector => + LocalCollection._selectorIsId(selector) || + LocalCollection._selectorIsId(selector && selector._id) && + Object.keys(selector).length === 1 +; + +LocalCollection._updateInResults = (query, doc, old_doc) => { + if (!EJSON.equals(doc._id, old_doc._id)) { + throw new Error('Can\'t change a doc\'s _id while updating'); + } + + const projectionFn = query.projectionFn; + const changedFields = DiffSequence.makeChangedFields( + projectionFn(doc), + projectionFn(old_doc) + ); + + if (!query.ordered) { + if (Object.keys(changedFields).length) { + query.changed(doc._id, changedFields); + query.results.set(doc._id, doc); + } + + return; + } + + const old_idx = LocalCollection._findInOrderedResults(query, doc); + + if (Object.keys(changedFields).length) { + query.changed(doc._id, changedFields); + } + + if (!query.sorter) { + return; + } + + // just take it out and put it back in again, and see if the index changes + query.results.splice(old_idx, 1); + + const new_idx = LocalCollection._insertInSortedList( + query.sorter.getComparator({distances: query.distances}), + query.results, + doc + ); + + if (old_idx !== new_idx) { + let next = query.results[new_idx + 1]; + if (next) { + next = next._id; + } else { + next = null; + } + + query.movedBefore && query.movedBefore(doc._id, next); + } +}; + +const MODIFIERS = { + $currentDate(target, field, arg) { + if (typeof arg === 'object' && hasOwn.call(arg, '$type')) { + if (arg.$type !== 'date') { + throw MinimongoError( + 'Minimongo does currently only support the date type in ' + + '$currentDate modifiers', + {field} + ); + } + } else if (arg !== true) { + throw MinimongoError('Invalid $currentDate modifier', {field}); + } + + target[field] = new Date(); + }, + $min(target, field, arg) { + if (typeof arg !== 'number') { + throw MinimongoError('Modifier $min allowed for numbers only', {field}); + } + + if (field in target) { + if (typeof target[field] !== 'number') { + throw MinimongoError( + 'Cannot apply $min modifier to non-number', + {field} + ); + } + + if (target[field] > arg) { + target[field] = arg; + } + } else { + target[field] = arg; + } + }, + $max(target, field, arg) { + if (typeof arg !== 'number') { + throw MinimongoError('Modifier $max allowed for numbers only', {field}); + } + + if (field in target) { + if (typeof target[field] !== 'number') { + throw MinimongoError( + 'Cannot apply $max modifier to non-number', + {field} + ); + } + + if (target[field] < arg) { + target[field] = arg; + } + } else { + target[field] = arg; + } + }, + $inc(target, field, arg) { + if (typeof arg !== 'number') { + throw MinimongoError('Modifier $inc allowed for numbers only', {field}); + } + + if (field in target) { + if (typeof target[field] !== 'number') { + throw MinimongoError( + 'Cannot apply $inc modifier to non-number', + {field} + ); + } + + target[field] += arg; + } else { + target[field] = arg; + } + }, + $set(target, field, arg) { + if (target !== Object(target)) { // not an array or an object + const error = MinimongoError( + 'Cannot set property on non-object field', + {field} + ); + error.setPropertyError = true; + throw error; + } + + if (target === null) { + const error = MinimongoError('Cannot set property on null', {field}); + error.setPropertyError = true; + throw error; + } + + assertHasValidFieldNames(arg); + + target[field] = arg; + }, + $setOnInsert(target, field, arg) { + // converted to `$set` in `_modify` + }, + $unset(target, field, arg) { + if (target !== undefined) { + if (target instanceof Array) { + if (field in target) { + target[field] = null; + } + } else { + delete target[field]; + } + } + }, + $push(target, field, arg) { + if (target[field] === undefined) { + target[field] = []; + } + + if (!(target[field] instanceof Array)) { + throw MinimongoError('Cannot apply $push modifier to non-array', {field}); + } + + if (!(arg && arg.$each)) { + // Simple mode: not $each + assertHasValidFieldNames(arg); + + target[field].push(arg); + + return; + } + + // Fancy mode: $each (and maybe $slice and $sort and $position) + const toPush = arg.$each; + if (!(toPush instanceof Array)) { + throw MinimongoError('$each must be an array', {field}); + } + + assertHasValidFieldNames(toPush); + + // Parse $position + let position = undefined; + if ('$position' in arg) { + if (typeof arg.$position !== 'number') { + throw MinimongoError('$position must be a numeric value', {field}); + } + + // XXX should check to make sure integer + if (arg.$position < 0) { + throw MinimongoError( + '$position in $push must be zero or positive', + {field} + ); + } + + position = arg.$position; + } + + // Parse $slice. + let slice = undefined; + if ('$slice' in arg) { + if (typeof arg.$slice !== 'number') { + throw MinimongoError('$slice must be a numeric value', {field}); + } + + // XXX should check to make sure integer + slice = arg.$slice; + } + + // Parse $sort. + let sortFunction = undefined; + if (arg.$sort) { + if (slice === undefined) { + throw MinimongoError('$sort requires $slice to be present', {field}); + } + + // XXX this allows us to use a $sort whose value is an array, but that's + // actually an extension of the Node driver, so it won't work + // server-side. Could be confusing! + // XXX is it correct that we don't do geo-stuff here? + sortFunction = new Minimongo.Sorter(arg.$sort).getComparator(); + + toPush.forEach(element => { + if (LocalCollection._f._type(element) !== 3) { + throw MinimongoError( + '$push like modifiers using $sort require all elements to be ' + + 'objects', + {field} + ); + } + }); + } + + // Actually push. + if (position === undefined) { + toPush.forEach(element => { + target[field].push(element); + }); + } else { + const spliceArguments = [position, 0]; + + toPush.forEach(element => { + spliceArguments.push(element); + }); + + target[field].splice(...spliceArguments); + } + + // Actually sort. + if (sortFunction) { + target[field].sort(sortFunction); + } + + // Actually slice. + if (slice !== undefined) { + if (slice === 0) { + target[field] = []; // differs from Array.slice! + } else if (slice < 0) { + target[field] = target[field].slice(slice); + } else { + target[field] = target[field].slice(0, slice); + } + } + }, + $pushAll(target, field, arg) { + if (!(typeof arg === 'object' && arg instanceof Array)) { + throw MinimongoError('Modifier $pushAll/pullAll allowed for arrays only'); + } + + assertHasValidFieldNames(arg); + + const toPush = target[field]; + + if (toPush === undefined) { + target[field] = arg; + } else if (!(toPush instanceof Array)) { + throw MinimongoError( + 'Cannot apply $pushAll modifier to non-array', + {field} + ); + } else { + toPush.push(...arg); + } + }, + $addToSet(target, field, arg) { + let isEach = false; + + if (typeof arg === 'object') { + // check if first key is '$each' + const keys = Object.keys(arg); + if (keys[0] === '$each') { + isEach = true; + } + } + + const values = isEach ? arg.$each : [arg]; + + assertHasValidFieldNames(values); + + const toAdd = target[field]; + if (toAdd === undefined) { + target[field] = values; + } else if (!(toAdd instanceof Array)) { + throw MinimongoError( + 'Cannot apply $addToSet modifier to non-array', + {field} + ); + } else { + values.forEach(value => { + if (toAdd.some(element => LocalCollection._f._equal(value, element))) { + return; + } + + toAdd.push(value); + }); + } + }, + $pop(target, field, arg) { + if (target === undefined) { + return; + } + + const toPop = target[field]; + + if (toPop === undefined) { + return; + } + + if (!(toPop instanceof Array)) { + throw MinimongoError('Cannot apply $pop modifier to non-array', {field}); + } + + if (typeof arg === 'number' && arg < 0) { + toPop.splice(0, 1); + } else { + toPop.pop(); + } + }, + $pull(target, field, arg) { + if (target === undefined) { + return; + } + + const toPull = target[field]; + if (toPull === undefined) { + return; + } + + if (!(toPull instanceof Array)) { + throw MinimongoError( + 'Cannot apply $pull/pullAll modifier to non-array', + {field} + ); + } + + let out; + if (arg != null && typeof arg === 'object' && !(arg instanceof Array)) { + // XXX would be much nicer to compile this once, rather than + // for each document we modify.. but usually we're not + // modifying that many documents, so we'll let it slide for + // now + + // XXX Minimongo.Matcher isn't up for the job, because we need + // to permit stuff like {$pull: {a: {$gt: 4}}}.. something + // like {$gt: 4} is not normally a complete selector. + // same issue as $elemMatch possibly? + const matcher = new Minimongo.Matcher(arg); + + out = toPull.filter(element => !matcher.documentMatches(element).result); + } else { + out = toPull.filter(element => !LocalCollection._f._equal(element, arg)); + } + + target[field] = out; + }, + $pullAll(target, field, arg) { + if (!(typeof arg === 'object' && arg instanceof Array)) { + throw MinimongoError( + 'Modifier $pushAll/pullAll allowed for arrays only', + {field} + ); + } + + if (target === undefined) { + return; + } + + const toPull = target[field]; + + if (toPull === undefined) { + return; + } + + if (!(toPull instanceof Array)) { + throw MinimongoError( + 'Cannot apply $pull/pullAll modifier to non-array', + {field} + ); + } + + target[field] = toPull.filter(object => + !arg.some(element => LocalCollection._f._equal(object, element)) + ); + }, + $rename(target, field, arg, keypath, doc) { + // no idea why mongo has this restriction.. + if (keypath === arg) { + throw MinimongoError('$rename source must differ from target', {field}); + } + + if (target === null) { + throw MinimongoError('$rename source field invalid', {field}); + } + + if (typeof arg !== 'string') { + throw MinimongoError('$rename target must be a string', {field}); + } + + if (arg.includes('\0')) { + // Null bytes are not allowed in Mongo field names + // https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names + throw MinimongoError( + 'The \'to\' field for $rename cannot contain an embedded null byte', + {field} + ); + } + + if (target === undefined) { + return; + } + + const object = target[field]; + + delete target[field]; + + const keyparts = arg.split('.'); + const target2 = findModTarget(doc, keyparts, {forbidArray: true}); + + if (target2 === null) { + throw MinimongoError('$rename target field invalid', {field}); + } + + target2[keyparts.pop()] = object; + }, + $bit(target, field, arg) { + // XXX mongo only supports $bit on integers, and we only support + // native javascript numbers (doubles) so far, so we can't support $bit + throw MinimongoError('$bit is not supported', {field}); + }, +}; + +const NO_CREATE_MODIFIERS = { + $pop: true, + $pull: true, + $pullAll: true, + $rename: true, + $unset: true +}; + +// Make sure field names do not contain Mongo restricted +// characters ('.', '$', '\0'). +// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names +const invalidCharMsg = { + $: 'start with \'$\'', + '.': 'contain \'.\'', + '\0': 'contain null bytes' +}; + +// checks if all field names in an object are valid +function assertHasValidFieldNames(doc) { + if (doc && typeof doc === 'object') { + JSON.stringify(doc, (key, value) => { + assertIsValidFieldName(key); + return value; + }); + } +} + +function assertIsValidFieldName(key) { + let match; + if (typeof key === 'string' && (match = key.match(/^\$|\.|\0/))) { + throw MinimongoError(`Key ${key} must not ${invalidCharMsg[match[0]]}`); + } +} + +// for a.b.c.2.d.e, keyparts should be ['a', 'b', 'c', '2', 'd', 'e'], +// and then you would operate on the 'e' property of the returned +// object. +// +// if options.noCreate is falsey, creates intermediate levels of +// structure as necessary, like mkdir -p (and raises an exception if +// that would mean giving a non-numeric property to an array.) if +// options.noCreate is true, return undefined instead. +// +// may modify the last element of keyparts to signal to the caller that it needs +// to use a different value to index into the returned object (for example, +// ['a', '01'] -> ['a', 1]). +// +// if forbidArray is true, return null if the keypath goes through an array. +// +// if options.arrayIndices is set, use its first element for the (first) '$' in +// the path. +function findModTarget(doc, keyparts, options = {}) { + let usedArrayIndex = false; + + for (let i = 0; i < keyparts.length; i++) { + const last = i === keyparts.length - 1; + let keypart = keyparts[i]; + + if (!isIndexable(doc)) { + if (options.noCreate) { + return undefined; + } + + const error = MinimongoError( + `cannot use the part '${keypart}' to traverse ${doc}` + ); + error.setPropertyError = true; + throw error; + } + + if (doc instanceof Array) { + if (options.forbidArray) { + return null; + } + + if (keypart === '$') { + if (usedArrayIndex) { + throw MinimongoError('Too many positional (i.e. \'$\') elements'); + } + + if (!options.arrayIndices || !options.arrayIndices.length) { + throw MinimongoError( + 'The positional operator did not find the match needed from the ' + + 'query' + ); + } + + keypart = options.arrayIndices[0]; + usedArrayIndex = true; + } else if (isNumericKey(keypart)) { + keypart = parseInt(keypart); + } else { + if (options.noCreate) { + return undefined; + } + + throw MinimongoError( + `can't append to array using string field name [${keypart}]` + ); + } + + if (last) { + keyparts[i] = keypart; // handle 'a.01' + } + + if (options.noCreate && keypart >= doc.length) { + return undefined; + } + + while (doc.length < keypart) { + doc.push(null); + } + + if (!last) { + if (doc.length === keypart) { + doc.push({}); + } else if (typeof doc[keypart] !== 'object') { + throw MinimongoError( + `can't modify field '${keyparts[i + 1]}' of list value ` + + JSON.stringify(doc[keypart]) + ); + } + } + } else { + assertIsValidFieldName(keypart); + + if (!(keypart in doc)) { + if (options.noCreate) { + return undefined; + } + + if (!last) { + doc[keypart] = {}; + } + } + } + + if (last) { + return doc; + } + + doc = doc[keypart]; + } + + // notreached +} diff --git a/packages/minimongo/matcher.js b/packages/minimongo/matcher.js new file mode 100644 index 00000000000..ad48271166e --- /dev/null +++ b/packages/minimongo/matcher.js @@ -0,0 +1,351 @@ +import LocalCollection from './local_collection.js'; +import { + compileDocumentSelector, + hasOwn, + nothingMatcher, +} from './common.js'; + +// The minimongo selector compiler! + +// Terminology: +// - a 'selector' is the EJSON object representing a selector +// - a 'matcher' is its compiled form (whether a full Minimongo.Matcher +// object or one of the component lambdas that matches parts of it) +// - a 'result object' is an object with a 'result' field and maybe +// distance and arrayIndices. +// - a 'branched value' is an object with a 'value' field and maybe +// 'dontIterate' and 'arrayIndices'. +// - a 'document' is a top-level object that can be stored in a collection. +// - a 'lookup function' is a function that takes in a document and returns +// an array of 'branched values'. +// - a 'branched matcher' maps from an array of branched values to a result +// object. +// - an 'element matcher' maps from a single value to a bool. + +// Main entry point. +// var matcher = new Minimongo.Matcher({a: {$gt: 5}}); +// if (matcher.documentMatches({a: 7})) ... +export default class Matcher { + constructor(selector, isUpdate) { + // A set (object mapping string -> *) of all of the document paths looked + // at by the selector. Also includes the empty string if it may look at any + // path (eg, $where). + this._paths = {}; + // Set to true if compilation finds a $near. + this._hasGeoQuery = false; + // Set to true if compilation finds a $where. + this._hasWhere = false; + // Set to false if compilation finds anything other than a simple equality + // or one or more of '$gt', '$gte', '$lt', '$lte', '$ne', '$in', '$nin' used + // with scalars as operands. + this._isSimple = true; + // Set to a dummy document which always matches this Matcher. Or set to null + // if such document is too hard to find. + this._matchingDocument = undefined; + // A clone of the original selector. It may just be a function if the user + // passed in a function; otherwise is definitely an object (eg, IDs are + // translated into {_id: ID} first. Used by canBecomeTrueByModifier and + // Sorter._useWithMatcher. + this._selector = null; + this._docMatcher = this._compileSelector(selector); + // Set to true if selection is done for an update operation + // Default is false + // Used for $near array update (issue #3599) + this._isUpdate = isUpdate; + } + + documentMatches(doc) { + if (doc !== Object(doc)) { + throw Error('documentMatches needs a document'); + } + + return this._docMatcher(doc); + } + + hasGeoQuery() { + return this._hasGeoQuery; + } + + hasWhere() { + return this._hasWhere; + } + + isSimple() { + return this._isSimple; + } + + // Given a selector, return a function that takes one argument, a + // document. It returns a result object. + _compileSelector(selector) { + // you can pass a literal function instead of a selector + if (selector instanceof Function) { + this._isSimple = false; + this._selector = selector; + this._recordPathUsed(''); + + return doc => ({result: !!selector.call(doc)}); + } + + // shorthand -- scalar _id and {_id} + if (LocalCollection._selectorIsIdPerhapsAsObject(selector)) { + const _id = selector._id || selector; + + this._selector = {_id}; + this._recordPathUsed('_id'); + + return doc => ({result: EJSON.equals(doc._id, _id)}); + } + + // protect against dangerous selectors. falsey and {_id: falsey} are both + // likely programmer error, and not what you want, particularly for + // destructive operations. + if (!selector || hasOwn.call(selector, '_id') && !selector._id) { + this._isSimple = false; + return nothingMatcher; + } + + // Top level can't be an array or true or binary. + if (Array.isArray(selector) || + EJSON.isBinary(selector) || + typeof selector === 'boolean') { + throw new Error(`Invalid selector: ${selector}`); + } + + this._selector = EJSON.clone(selector); + + return compileDocumentSelector(selector, this, {isRoot: true}); + } + + // Returns a list of key paths the given selector is looking for. It includes + // the empty string if there is a $where. + _getPaths() { + return Object.keys(this._paths); + } + + _recordPathUsed(path) { + this._paths[path] = true; + } +} + +// helpers used by compiled selector code +LocalCollection._f = { + // XXX for _all and _in, consider building 'inquery' at compile time.. + _type(v) { + if (typeof v === 'number') { + return 1; + } + + if (typeof v === 'string') { + return 2; + } + + if (typeof v === 'boolean') { + return 8; + } + + if (Array.isArray(v)) { + return 4; + } + + if (v === null) { + return 10; + } + + // note that typeof(/x/) === "object" + if (v instanceof RegExp) { + return 11; + } + + if (typeof v === 'function') { + return 13; + } + + if (v instanceof Date) { + return 9; + } + + if (EJSON.isBinary(v)) { + return 5; + } + + if (v instanceof MongoID.ObjectID) { + return 7; + } + + // object + return 3; + + // 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(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(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(a, b) { + if (a === undefined) { + return b === undefined ? 0 : -1; + } + + if (b === undefined) { + return 1; + } + + let ta = LocalCollection._f._type(a); + let tb = LocalCollection._f._type(b); + + const oa = LocalCollection._f._typeorder(ta); + const ob = LocalCollection._f._typeorder(tb); + + if (oa !== ob) { + return oa < ob ? -1 : 1; + } + + // XXX need to implement this if we implement Symbol or integers, or + // Timestamp + if (ta !== tb) { + 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 ... + const toArray = object => { + const result = []; + + for (let key in object) { + result.push(key); + result.push(object[key]); + } + + return result; + }; + + return LocalCollection._f._cmp(toArray(a), toArray(b)); + } + + if (ta === 4) { // Array + for (let i = 0; ; i++) { + if (i === a.length) { + return i === b.length ? 0 : -1; + } + + if (i === b.length) { + return 1; + } + + const 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 (let 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'); + }, +}; diff --git a/packages/minimongo/minimongo.js b/packages/minimongo/minimongo.js deleted file mode 100644 index 28df4180ece..00000000000 --- a/packages/minimongo/minimongo.js +++ /dev/null @@ -1,1167 +0,0 @@ -import { assertHasValidFieldNames } from './validation.js'; -import populateDocumentWithQueryFields from './upsert_document.js'; - -// XXX type checking on selectors (graceful error if malformed) - -// LocalCollection: a set of documents that supports queries and modifiers. - -// Cursor: a specification for a particular subset of documents, w/ -// a defined order, limit, and offset. creating a Cursor with LocalCollection.find(), - -// ObserveHandle: the return value of a live query. - -LocalCollection = function (name) { - var self = this; - self.name = name; - // _id -> document (also containing id) - self._docs = new LocalCollection._IdMap; - - self._observeQueue = new Meteor._SynchronousQueue(); - - self.next_qid = 1; // live query id generator - - // qid -> live query object. keys: - // ordered: bool. ordered queries have addedBefore/movedBefore callbacks. - // results: array (ordered) or object (unordered) of current results - // (aliased with self._docs!) - // resultsSnapshot: snapshot of results. null if not paused. - // cursor: Cursor object for the query. - // selector, sorter, (callbacks): functions - self.queries = {}; - - // null if not saving originals; an IdMap from id to original document value if - // saving originals. See comments before saveOriginals(). - self._savedOriginals = null; - - // True when observers are paused and we should not send callbacks. - self.paused = false; -}; - -Minimongo = {}; - -// Object exported only for unit testing. -// Use it to export private functions to test in Tinytest. -MinimongoTest = {}; - -MinimongoError = function (message, options={}) { - if (typeof message === "string" && options.field) { - message += ` for field '${options.field}'`; - } - - var e = new Error(message); - e.name = "MinimongoError"; - return e; -}; - - -// options may include sort, skip, limit, reactive -// sort may be any of these forms: -// {a: 1, b: -1} -// [["a", "asc"], ["b", "desc"]] -// ["a", ["b", "desc"]] -// (in the first form you're beholden to key enumeration order in -// your javascript VM) -// -// reactive: if given, and false, don't register with Tracker (default -// is true) -// -// XXX possibly should support retrieving a subset of fields? and -// have it be a hint (ignored on the client, when not copying the -// doc?) -// -// XXX sort does not yet support subkeys ('a.b') .. fix that! -// XXX add one more sort form: "key" -// XXX tests -LocalCollection.prototype.find = function (selector, options) { - // default syntax for everything is to omit the selector argument. - // but if selector is explicitly passed in as false or undefined, we - // want a selector that matches nothing. - if (arguments.length === 0) - selector = {}; - - return new LocalCollection.Cursor(this, selector, options); -}; - -// don't call this ctor directly. use LocalCollection.find(). - -LocalCollection.Cursor = function (collection, selector, options) { - var self = this; - if (!options) options = {}; - - self.collection = collection; - self.sorter = null; - self.matcher = new Minimongo.Matcher(selector); - - if (LocalCollection._selectorIsId(selector)) { - // stash for fast path - self._selectorId = selector; - } else if (LocalCollection._selectorIsIdPerhapsAsObject(selector)) { - // also do the fast path for { _id: idString } - self._selectorId = selector._id; - } else { - self._selectorId = undefined; - if (self.matcher.hasGeoQuery() || options.sort) { - self.sorter = new Minimongo.Sorter(options.sort || [], - { matcher: self.matcher }); - } - } - - self.skip = options.skip; - self.limit = options.limit; - self.fields = options.fields; - - self._projectionFn = LocalCollection._compileProjection(self.fields || {}); - - self._transform = LocalCollection.wrapTransform(options.transform); - - // by default, queries register w/ Tracker when it is available. - if (typeof Tracker !== "undefined") - self.reactive = (options.reactive === undefined) ? true : options.reactive; -}; - -// Since we don't actually have a "nextObject" interface, there's really no -// reason to have a "rewind" interface. All it did was make multiple calls -// to fetch/map/forEach return nothing the second time. -// XXX COMPAT WITH 0.8.1 -LocalCollection.Cursor.prototype.rewind = function () { -}; - -LocalCollection.prototype.findOne = function (selector, options) { - if (arguments.length === 0) - selector = {}; - - // NOTE: by setting limit 1 here, we end up using very inefficient - // code that recomputes the whole query on each update. The upside is - // that when you reactively depend on a findOne you only get - // invalidated when the found object changes, not any object in the - // collection. Most findOne will be by id, which has a fast path, so - // this might not be a big deal. In most cases, invalidation causes - // the called to re-query anyway, so this should be a net performance - // improvement. - options = options || {}; - options.limit = 1; - - return this.find(selector, options).fetch()[0]; -}; - -/** - * @callback IterationCallback - * @param {Object} doc - * @param {Number} index - */ -/** - * @summary Call `callback` once for each matching document, sequentially and synchronously. - * @locus Anywhere - * @method forEach - * @instance - * @memberOf Mongo.Cursor - * @param {IterationCallback} callback Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself. - * @param {Any} [thisArg] An object which will be the value of `this` inside `callback`. - */ -LocalCollection.Cursor.prototype.forEach = function (callback, thisArg) { - var self = this; - - var objects = self._getRawObjects({ordered: true}); - - if (self.reactive) { - self._depend({ - addedBefore: true, - removed: true, - changed: true, - movedBefore: true}); - } - - _.each(objects, function (elt, i) { - // This doubles as a clone operation. - elt = self._projectionFn(elt); - - if (self._transform) - elt = self._transform(elt); - callback.call(thisArg, elt, i, self); - }); -}; - -LocalCollection.Cursor.prototype.getTransform = function () { - return this._transform; -}; - -/** - * @summary Map callback over all matching documents. Returns an Array. - * @locus Anywhere - * @method map - * @instance - * @memberOf Mongo.Cursor - * @param {IterationCallback} callback Function to call. It will be called with three arguments: the document, a 0-based index, and cursor itself. - * @param {Any} [thisArg] An object which will be the value of `this` inside `callback`. - */ -LocalCollection.Cursor.prototype.map = function (callback, thisArg) { - var self = this; - var res = []; - self.forEach(function (doc, index) { - res.push(callback.call(thisArg, doc, index, self)); - }); - return res; -}; - -/** - * @summary Return all matching documents as an Array. - * @memberOf Mongo.Cursor - * @method fetch - * @instance - * @locus Anywhere - * @returns {Object[]} - */ -LocalCollection.Cursor.prototype.fetch = function () { - var self = this; - var res = []; - self.forEach(function (doc) { - res.push(doc); - }); - return res; -}; - -/** - * @summary Returns the number of documents that match a query. - * @memberOf Mongo.Cursor - * @method count - * @instance - * @locus Anywhere - * @returns {Number} - */ -LocalCollection.Cursor.prototype.count = function () { - var self = this; - - if (self.reactive) - self._depend({added: true, removed: true}, - true /* allow the observe to be unordered */); - - return self._getRawObjects({ordered: true}).length; -}; - -LocalCollection.Cursor.prototype._publishCursor = function (sub) { - var self = this; - if (! self.collection.name) - throw new Error("Can't publish a cursor from a collection without a name."); - var collection = self.collection.name; - - // XXX minimongo should not depend on mongo-livedata! - if (! Package.mongo) { - throw new Error("Can't publish from Minimongo without the `mongo` package."); - } - - return Package.mongo.Mongo.Collection._publishCursor(self, sub, collection); -}; - -LocalCollection.Cursor.prototype._getCollectionName = function () { - var self = this; - return self.collection.name; -}; - -LocalCollection._observeChangesCallbacksAreOrdered = function (callbacks) { - if (callbacks.added && callbacks.addedBefore) - throw new Error("Please specify only one of added() and addedBefore()"); - return !!(callbacks.addedBefore || callbacks.movedBefore); -}; - -LocalCollection._observeCallbacksAreOrdered = function (callbacks) { - if (callbacks.addedAt && callbacks.added) - throw new Error("Please specify only one of added() and addedAt()"); - if (callbacks.changedAt && callbacks.changed) - throw new Error("Please specify only one of changed() and changedAt()"); - if (callbacks.removed && callbacks.removedAt) - throw new Error("Please specify only one of removed() and removedAt()"); - - return !!(callbacks.addedAt || callbacks.movedTo || callbacks.changedAt - || callbacks.removedAt); -}; - -// the handle that comes back from observe. -LocalCollection.ObserveHandle = function () {}; - -// options to contain: -// * callbacks for observe(): -// - addedAt (document, atIndex) -// - added (document) -// - changedAt (newDocument, oldDocument, atIndex) -// - changed (newDocument, oldDocument) -// - removedAt (document, atIndex) -// - removed (document) -// - movedTo (document, oldIndex, newIndex) -// -// attributes available on returned query handle: -// * stop(): end updates -// * collection: the collection this query is querying -// -// iff x is a returned query handle, (x instanceof -// LocalCollection.ObserveHandle) is true -// -// initial results delivered through added callback -// XXX maybe callbacks should take a list of objects, to expose transactions? -// XXX maybe support field limiting (to limit what you're notified on) - -_.extend(LocalCollection.Cursor.prototype, { - /** - * @summary Watch a query. Receive callbacks as the result set changes. - * @locus Anywhere - * @memberOf Mongo.Cursor - * @instance - * @param {Object} callbacks Functions to call to deliver the result set as it changes - */ - observe: function (options) { - var self = this; - return LocalCollection._observeFromObserveChanges(self, options); - }, - - /** - * @summary Watch a query. Receive callbacks as the result set changes. Only the differences between the old and new documents are passed to the callbacks. - * @locus Anywhere - * @memberOf Mongo.Cursor - * @instance - * @param {Object} callbacks Functions to call to deliver the result set as it changes - */ - observeChanges: function (options) { - var self = this; - - var ordered = LocalCollection._observeChangesCallbacksAreOrdered(options); - - // there are several places that assume you aren't combining skip/limit with - // unordered observe. eg, update's EJSON.clone, and the "there are several" - // comment in _modifyAndNotify - // XXX allow skip/limit with unordered observe - if (!options._allow_unordered && !ordered && (self.skip || self.limit)) - throw new Error("must use ordered observe (ie, 'addedBefore' instead of 'added') with skip or limit"); - - if (self.fields && (self.fields._id === 0 || self.fields._id === false)) - throw Error("You may not observe a cursor with {fields: {_id: 0}}"); - - var query = { - dirty: false, - matcher: self.matcher, // not fast pathed - sorter: ordered && self.sorter, - distances: ( - self.matcher.hasGeoQuery() && ordered && new LocalCollection._IdMap), - resultsSnapshot: null, - ordered: ordered, - cursor: self, - projectionFn: self._projectionFn - }; - var qid; - - // Non-reactive queries call added[Before] and then never call anything - // else. - if (self.reactive) { - qid = self.collection.next_qid++; - self.collection.queries[qid] = query; - } - query.results = self._getRawObjects({ - ordered: ordered, distances: query.distances}); - if (self.collection.paused) - query.resultsSnapshot = (ordered ? [] : new LocalCollection._IdMap); - - // wrap callbacks we were passed. callbacks only fire when not paused and - // are never undefined - // Filters out blacklisted fields according to cursor's projection. - // XXX wrong place for this? - - // furthermore, callbacks enqueue until the operation we're working on is - // done. - var wrapCallback = function (f) { - if (!f) - return function () {}; - return function (/*args*/) { - var context = this; - var args = arguments; - - if (self.collection.paused) - return; - - self.collection._observeQueue.queueTask(function () { - f.apply(context, args); - }); - }; - }; - query.added = wrapCallback(options.added); - query.changed = wrapCallback(options.changed); - query.removed = wrapCallback(options.removed); - if (ordered) { - query.addedBefore = wrapCallback(options.addedBefore); - query.movedBefore = wrapCallback(options.movedBefore); - } - - if (!options._suppress_initial && !self.collection.paused) { - // XXX unify ordered and unordered interface - var each = ordered - ? _.bind(_.each, null, query.results) - : _.bind(query.results.forEach, query.results); - each(function (doc) { - var fields = EJSON.clone(doc); - - delete fields._id; - if (ordered) - query.addedBefore(doc._id, self._projectionFn(fields), null); - query.added(doc._id, self._projectionFn(fields)); - }); - } - - var handle = new LocalCollection.ObserveHandle; - _.extend(handle, { - collection: self.collection, - stop: function () { - if (self.reactive) - delete self.collection.queries[qid]; - } - }); - - if (self.reactive && Tracker.active) { - // XXX in many cases, the same observe will be recreated when - // the current autorun is rerun. we could save work by - // letting it linger across rerun and potentially get - // repurposed if the same observe is performed, using logic - // similar to that of Meteor.subscribe. - Tracker.onInvalidate(function () { - handle.stop(); - }); - } - // run the observe callbacks resulting from the initial contents - // before we leave the observe. - self.collection._observeQueue.drain(); - - return handle; - } -}); - -// Returns a collection of matching objects, but doesn't deep copy them. -// -// If ordered is set, returns a sorted array, respecting sorter, skip, and limit -// properties of the query. if sorter is falsey, no sort -- you get the natural -// order. -// -// If ordered is not set, returns an object mapping from ID to doc (sorter, skip -// and limit should not be set). -// -// If ordered is set and this cursor is a $near geoquery, then this function -// will use an _IdMap to track each distance from the $near argument point in -// order to use it as a sort key. If an _IdMap is passed in the 'distances' -// argument, this function will clear it and use it for this purpose (otherwise -// it will just create its own _IdMap). The observeChanges implementation uses -// this to remember the distances after this function returns. -LocalCollection.Cursor.prototype._getRawObjects = function (options) { - var self = this; - options = options || {}; - - // XXX use OrderedDict instead of array, and make IdMap and OrderedDict - // compatible - var results = options.ordered ? [] : new LocalCollection._IdMap; - - // fast path for single ID value - if (self._selectorId !== undefined) { - // If you have non-zero skip and ask for a single id, you get - // nothing. This is so it matches the behavior of the '{_id: foo}' - // path. - if (self.skip) - return results; - - var selectedDoc = self.collection._docs.get(self._selectorId); - if (selectedDoc) { - if (options.ordered) - results.push(selectedDoc); - else - results.set(self._selectorId, selectedDoc); - } - return results; - } - - // slow path for arbitrary selector, sort, skip, limit - - // in the observeChanges case, distances is actually part of the "query" (ie, - // live results set) object. in other cases, distances is only used inside - // this function. - var distances; - if (self.matcher.hasGeoQuery() && options.ordered) { - if (options.distances) { - distances = options.distances; - distances.clear(); - } else { - distances = new LocalCollection._IdMap(); - } - } - - self.collection._docs.forEach(function (doc, id) { - var matchResult = self.matcher.documentMatches(doc); - if (matchResult.result) { - if (options.ordered) { - results.push(doc); - if (distances && matchResult.distance !== undefined) - distances.set(id, matchResult.distance); - } else { - results.set(id, doc); - } - } - // Fast path for limited unsorted queries. - // XXX 'length' check here seems wrong for ordered - if (self.limit && !self.skip && !self.sorter && - results.length === self.limit) - return false; // break - return true; // continue - }); - - if (!options.ordered) - return results; - - if (self.sorter) { - var comparator = self.sorter.getComparator({distances: distances}); - results.sort(comparator); - } - - var idx_start = self.skip || 0; - var idx_end = self.limit ? (self.limit + idx_start) : results.length; - return results.slice(idx_start, idx_end); -}; - -// XXX Maybe we need a version of observe that just calls a callback if -// anything changed. -LocalCollection.Cursor.prototype._depend = function (changers, _allow_unordered) { - var self = this; - - if (Tracker.active) { - var v = new Tracker.Dependency; - v.depend(); - var notifyChange = _.bind(v.changed, v); - - var options = { - _suppress_initial: true, - _allow_unordered: _allow_unordered - }; - _.each(['added', 'changed', 'removed', 'addedBefore', 'movedBefore'], - function (fnName) { - if (changers[fnName]) - options[fnName] = notifyChange; - }); - - // observeChanges will stop() when this computation is invalidated - self.observeChanges(options); - } -}; - -// XXX possibly enforce that 'undefined' does not appear (we assume -// this in our handling of null and $exists) -LocalCollection.prototype.insert = function (doc, callback) { - var self = this; - doc = EJSON.clone(doc); - - assertHasValidFieldNames(doc); - - if (!_.has(doc, '_id')) { - // if you really want to use ObjectIDs, set this global. - // Mongo.Collection specifies its own ids and does not use this code. - doc._id = LocalCollection._useOID ? new MongoID.ObjectID() - : Random.id(); - } - var id = doc._id; - - if (self._docs.has(id)) - throw MinimongoError("Duplicate _id '" + id + "'"); - - self._saveOriginal(id, undefined); - self._docs.set(id, doc); - - var queriesToRecompute = []; - // trigger live queries that match - for (var qid in self.queries) { - var query = self.queries[qid]; - if (query.dirty) continue; - var matchResult = query.matcher.documentMatches(doc); - if (matchResult.result) { - if (query.distances && matchResult.distance !== undefined) - query.distances.set(id, matchResult.distance); - if (query.cursor.skip || query.cursor.limit) - queriesToRecompute.push(qid); - else - LocalCollection._insertInResults(query, doc); - } - } - - _.each(queriesToRecompute, function (qid) { - if (self.queries[qid]) - self._recomputeResults(self.queries[qid]); - }); - self._observeQueue.drain(); - - // Defer because the caller likely doesn't expect the callback to be run - // immediately. - if (callback) - Meteor.defer(function () { - callback(null, id); - }); - return id; -}; - -// Iterates over a subset of documents that could match selector; calls -// f(doc, id) on each of them. Specifically, if selector specifies -// specific _id's, it only looks at those. doc is *not* cloned: it is the -// same object that is in _docs. -LocalCollection.prototype._eachPossiblyMatchingDoc = function (selector, f) { - var self = this; - var specificIds = LocalCollection._idsMatchedBySelector(selector); - if (specificIds) { - for (var i = 0; i < specificIds.length; ++i) { - var id = specificIds[i]; - var doc = self._docs.get(id); - if (doc) { - var breakIfFalse = f(doc, id); - if (breakIfFalse === false) - break; - } - } - } else { - self._docs.forEach(f); - } -}; - -LocalCollection.prototype.remove = function (selector, callback) { - var self = this; - - // Easy special case: if we're not calling observeChanges callbacks and we're - // not saving originals and we got asked to remove everything, then just empty - // everything directly. - if (self.paused && !self._savedOriginals && EJSON.equals(selector, {})) { - var result = self._docs.size(); - self._docs.clear(); - _.each(self.queries, function (query) { - if (query.ordered) { - query.results = []; - } else { - query.results.clear(); - } - }); - if (callback) { - Meteor.defer(function () { - callback(null, result); - }); - } - return result; - } - - var matcher = new Minimongo.Matcher(selector); - var remove = []; - self._eachPossiblyMatchingDoc(selector, function (doc, id) { - if (matcher.documentMatches(doc).result) - remove.push(id); - }); - - var queriesToRecompute = []; - var queryRemove = []; - for (var i = 0; i < remove.length; i++) { - var removeId = remove[i]; - var removeDoc = self._docs.get(removeId); - _.each(self.queries, function (query, qid) { - if (query.dirty) return; - - if (query.matcher.documentMatches(removeDoc).result) { - if (query.cursor.skip || query.cursor.limit) - queriesToRecompute.push(qid); - else - queryRemove.push({qid: qid, doc: removeDoc}); - } - }); - self._saveOriginal(removeId, removeDoc); - self._docs.remove(removeId); - } - - // run live query callbacks _after_ we've removed the documents. - _.each(queryRemove, function (remove) { - var query = self.queries[remove.qid]; - if (query) { - query.distances && query.distances.remove(remove.doc._id); - LocalCollection._removeFromResults(query, remove.doc); - } - }); - _.each(queriesToRecompute, function (qid) { - var query = self.queries[qid]; - if (query) - self._recomputeResults(query); - }); - self._observeQueue.drain(); - result = remove.length; - if (callback) - Meteor.defer(function () { - callback(null, result); - }); - return result; -}; - -// XXX atomicity: if multi is true, and one modification fails, do -// we rollback the whole operation, or what? -LocalCollection.prototype.update = function (selector, mod, options, callback) { - var self = this; - if (! callback && options instanceof Function) { - callback = options; - options = null; - } - if (!options) options = {}; - - var matcher = new Minimongo.Matcher(selector, true); - - // Save the original results of any query that we might need to - // _recomputeResults on, because _modifyAndNotify will mutate the objects in - // it. (We don't need to save the original results of paused queries because - // they already have a resultsSnapshot and we won't be diffing in - // _recomputeResults.) - var qidToOriginalResults = {}; - // We should only clone each document once, even if it appears in multiple queries - var docMap = new LocalCollection._IdMap; - var idsMatchedBySelector = LocalCollection._idsMatchedBySelector(selector); - - _.each(self.queries, function (query, qid) { - if ((query.cursor.skip || query.cursor.limit) && ! self.paused) { - // Catch the case of a reactive `count()` on a cursor with skip - // or limit, which registers an unordered observe. This is a - // pretty rare case, so we just clone the entire result set with - // no optimizations for documents that appear in these result - // sets and other queries. - if (query.results instanceof LocalCollection._IdMap) { - qidToOriginalResults[qid] = query.results.clone(); - return; - } - - if (!(query.results instanceof Array)) { - throw new Error("Assertion failed: query.results not an array"); - } - - // Clones a document to be stored in `qidToOriginalResults` - // because it may be modified before the new and old result sets - // are diffed. But if we know exactly which document IDs we're - // going to modify, then we only need to clone those. - var memoizedCloneIfNeeded = function(doc) { - if (docMap.has(doc._id)) { - return docMap.get(doc._id); - } else { - var docToMemoize; - - if (idsMatchedBySelector && !_.any(idsMatchedBySelector, function(id) { - return EJSON.equals(id, doc._id); - })) { - docToMemoize = doc; - } else { - docToMemoize = EJSON.clone(doc); - } - - docMap.set(doc._id, docToMemoize); - return docToMemoize; - } - }; - - qidToOriginalResults[qid] = query.results.map(memoizedCloneIfNeeded); - } - }); - var recomputeQids = {}; - - var updateCount = 0; - - self._eachPossiblyMatchingDoc(selector, function (doc, id) { - var queryResult = matcher.documentMatches(doc); - if (queryResult.result) { - // XXX Should we save the original even if mod ends up being a no-op? - self._saveOriginal(id, doc); - self._modifyAndNotify(doc, mod, recomputeQids, queryResult.arrayIndices); - ++updateCount; - if (!options.multi) - return false; // break - } - return true; - }); - - _.each(recomputeQids, function (dummy, qid) { - var query = self.queries[qid]; - if (query) - self._recomputeResults(query, qidToOriginalResults[qid]); - }); - self._observeQueue.drain(); - - // If we are doing an upsert, and we didn't modify any documents yet, then - // it's time to do an insert. Figure out what document we are inserting, and - // generate an id for it. - var insertedId; - if (updateCount === 0 && options.upsert) { - - const newDoc = LocalCollection._createUpsertDocument(selector, mod); - - if (! newDoc._id && options.insertedId) - newDoc._id = options.insertedId; - insertedId = self.insert(newDoc); - updateCount = 1; - } - - // Return the number of affected documents, or in the upsert case, an object - // containing the number of affected docs and the id of the doc that was - // inserted, if any. - var result; - if (options._returnObject) { - result = { - numberAffected: updateCount - }; - if (insertedId !== undefined) - result.insertedId = insertedId; - } else { - result = updateCount; - } - - if (callback) - Meteor.defer(function () { - callback(null, result); - }); - return result; -}; - -// A convenience wrapper on update. LocalCollection.upsert(sel, mod) is -// equivalent to LocalCollection.update(sel, mod, { upsert: true, _returnObject: -// true }). -LocalCollection.prototype.upsert = function (selector, mod, options, callback) { - var self = this; - if (! callback && typeof options === "function") { - callback = options; - options = {}; - } - return self.update(selector, mod, _.extend({}, options, { - upsert: true, - _returnObject: true - }), callback); -}; - -LocalCollection.prototype._modifyAndNotify = function ( - doc, mod, recomputeQids, arrayIndices) { - var self = this; - - var matched_before = {}; - for (var qid in self.queries) { - var query = self.queries[qid]; - if (query.dirty) continue; - - if (query.ordered) { - matched_before[qid] = query.matcher.documentMatches(doc).result; - } else { - // Because we don't support skip or limit (yet) in unordered queries, we - // can just do a direct lookup. - matched_before[qid] = query.results.has(doc._id); - } - } - - var old_doc = EJSON.clone(doc); - - LocalCollection._modify(doc, mod, {arrayIndices: arrayIndices}); - - for (qid in self.queries) { - query = self.queries[qid]; - if (query.dirty) continue; - - var before = matched_before[qid]; - var afterMatch = query.matcher.documentMatches(doc); - var after = afterMatch.result; - if (after && query.distances && afterMatch.distance !== undefined) - query.distances.set(doc._id, afterMatch.distance); - - if (query.cursor.skip || query.cursor.limit) { - // We need to recompute any query where the doc may have been in the - // cursor's window either before or after the update. (Note that if skip - // or limit is set, "before" and "after" being true do not necessarily - // mean that the document is in the cursor's output after skip/limit is - // applied... but if they are false, then the document definitely is NOT - // in the output. So it's safe to skip recompute if neither before or - // after are true.) - if (before || after) - recomputeQids[qid] = true; - } else if (before && !after) { - LocalCollection._removeFromResults(query, doc); - } else if (!before && after) { - LocalCollection._insertInResults(query, doc); - } else if (before && after) { - LocalCollection._updateInResults(query, doc, old_doc); - } - } -}; - -// XXX the sorted-query logic below is laughably inefficient. we'll -// need to come up with a better datastructure for this. -// -// XXX the logic for observing with a skip or a limit is even more -// laughably inefficient. we recompute the whole results every time! - -LocalCollection._insertInResults = function (query, doc) { - var fields = EJSON.clone(doc); - delete fields._id; - if (query.ordered) { - if (!query.sorter) { - query.addedBefore(doc._id, query.projectionFn(fields), null); - query.results.push(doc); - } else { - var i = LocalCollection._insertInSortedList( - query.sorter.getComparator({distances: query.distances}), - query.results, doc); - var next = query.results[i+1]; - if (next) - next = next._id; - else - next = null; - query.addedBefore(doc._id, query.projectionFn(fields), next); - } - query.added(doc._id, query.projectionFn(fields)); - } else { - query.added(doc._id, query.projectionFn(fields)); - query.results.set(doc._id, doc); - } -}; - -LocalCollection._removeFromResults = function (query, doc) { - if (query.ordered) { - var i = LocalCollection._findInOrderedResults(query, doc); - query.removed(doc._id); - query.results.splice(i, 1); - } else { - var id = doc._id; // in case callback mutates doc - query.removed(doc._id); - query.results.remove(id); - } -}; - -LocalCollection._updateInResults = function (query, doc, old_doc) { - if (!EJSON.equals(doc._id, old_doc._id)) - throw new Error("Can't change a doc's _id while updating"); - var projectionFn = query.projectionFn; - var changedFields = DiffSequence.makeChangedFields( - projectionFn(doc), projectionFn(old_doc)); - - if (!query.ordered) { - if (!_.isEmpty(changedFields)) { - query.changed(doc._id, changedFields); - query.results.set(doc._id, doc); - } - return; - } - - var orig_idx = LocalCollection._findInOrderedResults(query, doc); - - if (!_.isEmpty(changedFields)) - query.changed(doc._id, changedFields); - if (!query.sorter) - return; - - // just take it out and put it back in again, and see if the index - // changes - query.results.splice(orig_idx, 1); - var new_idx = LocalCollection._insertInSortedList( - query.sorter.getComparator({distances: query.distances}), - query.results, doc); - if (orig_idx !== new_idx) { - var next = query.results[new_idx+1]; - if (next) - next = next._id; - else - next = null; - query.movedBefore && query.movedBefore(doc._id, next); - } -}; - -// Recomputes the results of a query and runs observe callbacks for the -// difference between the previous results and the current results (unless -// paused). Used for skip/limit queries. -// -// When this is used by insert or remove, it can just use query.results for the -// old results (and there's no need to pass in oldResults), because these -// operations don't mutate the documents in the collection. Update needs to pass -// in an oldResults which was deep-copied before the modifier was applied. -// -// oldResults is guaranteed to be ignored if the query is not paused. -LocalCollection.prototype._recomputeResults = function (query, oldResults) { - var self = this; - if (self.paused) { - // There's no reason to recompute the results now as we're still paused. - // By flagging the query as "dirty", the recompute will be performed - // when resumeObservers is called. - query.dirty = true; - return; - } - - if (! self.paused && ! oldResults) - oldResults = query.results; - if (query.distances) - query.distances.clear(); - query.results = query.cursor._getRawObjects({ - ordered: query.ordered, distances: query.distances}); - - if (! self.paused) { - LocalCollection._diffQueryChanges( - query.ordered, oldResults, query.results, query, - { projectionFn: query.projectionFn }); - } -}; - - -LocalCollection._findInOrderedResults = function (query, doc) { - if (!query.ordered) - throw new Error("Can't call _findInOrderedResults on unordered query"); - for (var i = 0; i < query.results.length; i++) - if (query.results[i] === doc) - return i; - throw Error("object missing from query"); -}; - -// This binary search puts a value between any equal values, and the first -// lesser value. -LocalCollection._binarySearch = function (cmp, array, value) { - var first = 0, rangeLength = array.length; - - while (rangeLength > 0) { - var halfRange = Math.floor(rangeLength/2); - if (cmp(value, array[first + halfRange]) >= 0) { - first += halfRange + 1; - rangeLength -= halfRange + 1; - } else { - rangeLength = halfRange; - } - } - return first; -}; - -LocalCollection._insertInSortedList = function (cmp, array, value) { - if (array.length === 0) { - array.push(value); - return 0; - } - - var idx = LocalCollection._binarySearch(cmp, array, value); - array.splice(idx, 0, value); - return idx; -}; - -// To track what documents are affected by a piece of code, call saveOriginals() -// before it and retrieveOriginals() after it. retrieveOriginals returns an -// object whose keys are the ids of the documents that were affected since the -// call to saveOriginals(), and the values are equal to the document's contents -// at the time of saveOriginals. (In the case of an inserted document, undefined -// is the value.) You must alternate between calls to saveOriginals() and -// retrieveOriginals(). -LocalCollection.prototype.saveOriginals = function () { - var self = this; - if (self._savedOriginals) - throw new Error("Called saveOriginals twice without retrieveOriginals"); - self._savedOriginals = new LocalCollection._IdMap; -}; -LocalCollection.prototype.retrieveOriginals = function () { - var self = this; - if (!self._savedOriginals) - throw new Error("Called retrieveOriginals without saveOriginals"); - - var originals = self._savedOriginals; - self._savedOriginals = null; - return originals; -}; - -LocalCollection.prototype._saveOriginal = function (id, doc) { - var self = this; - // Are we even trying to save originals? - if (!self._savedOriginals) - return; - // Have we previously mutated the original (and so 'doc' is not actually - // original)? (Note the 'has' check rather than truth: we store undefined - // here for inserted docs!) - if (self._savedOriginals.has(id)) - return; - self._savedOriginals.set(id, EJSON.clone(doc)); -}; - -// Pause the observers. No callbacks from observers will fire until -// 'resumeObservers' is called. -LocalCollection.prototype.pauseObservers = function () { - // No-op if already paused. - if (this.paused) - return; - - // Set the 'paused' flag such that new observer messages don't fire. - this.paused = true; - - // Take a snapshot of the query results for each query. - for (var qid in this.queries) { - var query = this.queries[qid]; - - query.resultsSnapshot = EJSON.clone(query.results); - } -}; - -// Resume the observers. Observers immediately receive change -// notifications to bring them to the current state of the -// database. Note that this is not just replaying all the changes that -// happened during the pause, it is a smarter 'coalesced' diff. -LocalCollection.prototype.resumeObservers = function () { - var self = this; - // No-op if not paused. - if (!this.paused) - return; - - // Unset the 'paused' flag. Make sure to do this first, otherwise - // observer methods won't actually fire when we trigger them. - this.paused = false; - - for (var qid in this.queries) { - var query = self.queries[qid]; - if (query.dirty) { - query.dirty = false; - // re-compute results will perform `LocalCollection._diffQueryChanges` automatically. - self._recomputeResults(query, query.resultsSnapshot); - } else { - // Diff the current results against the snapshot and send to observers. - // pass the query object for its observer callbacks. - LocalCollection._diffQueryChanges( - query.ordered, query.resultsSnapshot, query.results, query, - {projectionFn: query.projectionFn}); - } - query.resultsSnapshot = null; - } - self._observeQueue.drain(); -}; - -LocalCollection._isModificationMod = function (mod) { - var isReplace = false; - var isModify = false; - for (var k in mod) { - if (k.substr(0, 1) === '$') { - isModify = true; - } else { - isReplace = true; - } - } - if (isModify && isReplace) { - throw new Error( - "Update parameter cannot have both modifier and non-modifier fields."); - } - return isModify; -}; - -// Calculates the document to insert in case we're doing an upsert and the selector -// does not match any elements -LocalCollection._createUpsertDocument = function (selector, modifier) { - let selectorDocument = populateDocumentWithQueryFields(selector); - const isModify = LocalCollection._isModificationMod(modifier); - - const newDoc = {}; - if (selectorDocument._id) { - newDoc._id = selectorDocument._id; - delete selectorDocument._id; - } - - // This double _modify call is made to help with nested properties (see issue #8631). - // We do this even if it's a replacement for validation purposes (e.g. ambiguous id's) - LocalCollection._modify(newDoc, { $set: selectorDocument }); - LocalCollection._modify(newDoc, modifier, { isInsert: true }); - - if (isModify) { - return newDoc; - } - - // Replacement can take _id from query document - const replacement = Object.assign({}, modifier); - if (newDoc._id) { - replacement._id = newDoc._id; - } - - return replacement -}; diff --git a/packages/minimongo/minimongo_client.js b/packages/minimongo/minimongo_client.js new file mode 100644 index 00000000000..cf05a1543a1 --- /dev/null +++ b/packages/minimongo/minimongo_client.js @@ -0,0 +1 @@ +import './minimongo_common.js'; diff --git a/packages/minimongo/minimongo_common.js b/packages/minimongo/minimongo_common.js new file mode 100644 index 00000000000..15a4e161f01 --- /dev/null +++ b/packages/minimongo/minimongo_common.js @@ -0,0 +1,10 @@ +import LocalCollection_ from './local_collection.js'; +import Matcher from './matcher.js'; +import Sorter from './sorter.js'; + +LocalCollection = LocalCollection_; +Minimongo = { + LocalCollection: LocalCollection_, + Matcher, + Sorter +}; diff --git a/packages/minimongo/minimongo_server.js b/packages/minimongo/minimongo_server.js new file mode 100644 index 00000000000..373bbe8e074 --- /dev/null +++ b/packages/minimongo/minimongo_server.js @@ -0,0 +1,346 @@ +import './minimongo_common.js'; +import { + hasOwn, + isNumericKey, + isOperatorObject, + pathsToTree, + projectionDetails, +} from './common.js'; + +Minimongo._pathsElidingNumericKeys = paths => paths.map(path => + path.split('.').filter(part => !isNumericKey(part)).join('.') +); + +// Returns true if the modifier applied to some document may change the result +// of matching the document by selector +// The modifier is always in a form of Object: +// - $set +// - 'a.b.22.z': value +// - 'foo.bar': 42 +// - $unset +// - 'abc.d': 1 +Minimongo.Matcher.prototype.affectedByModifier = function(modifier) { + // safe check for $set/$unset being objects + modifier = Object.assign({$set: {}, $unset: {}}, modifier); + + const meaningfulPaths = this._getPaths(); + const modifiedPaths = [].concat( + Object.keys(modifier.$set), + Object.keys(modifier.$unset) + ); + + return modifiedPaths.some(path => { + const mod = path.split('.'); + + return meaningfulPaths.some(meaningfulPath => { + const sel = meaningfulPath.split('.'); + + let i = 0, j = 0; + + while (i < sel.length && j < mod.length) { + if (isNumericKey(sel[i]) && isNumericKey(mod[j])) { + // foo.4.bar selector affected by foo.4 modifier + // foo.3.bar selector unaffected by foo.4 modifier + if (sel[i] === mod[j]) { + i++; + j++; + } else { + return false; + } + } else if (isNumericKey(sel[i])) { + // foo.4.bar selector unaffected by foo.bar modifier + return false; + } else if (isNumericKey(mod[j])) { + j++; + } else if (sel[i] === mod[j]) { + i++; + j++; + } else { + return false; + } + } + + // One is a prefix of another, taking numeric fields into account + return true; + }); + }); +}; + +// @param modifier - Object: MongoDB-styled modifier with `$set`s and `$unsets` +// only. (assumed to come from oplog) +// @returns - Boolean: if after applying the modifier, selector can start +// accepting the modified value. +// NOTE: assumes that document affected by modifier didn't match this Matcher +// before, so if modifier can't convince selector in a positive change it would +// stay 'false'. +// Currently doesn't support $-operators and numeric indices precisely. +Minimongo.Matcher.prototype.canBecomeTrueByModifier = function(modifier) { + if (!this.affectedByModifier(modifier)) { + return false; + } + + if (!this.isSimple()) { + return true; + } + + modifier = Object.assign({$set: {}, $unset: {}}, modifier); + + const modifierPaths = [].concat( + Object.keys(modifier.$set), + Object.keys(modifier.$unset) + ); + + if (this._getPaths().some(pathHasNumericKeys) || + modifierPaths.some(pathHasNumericKeys)) { + return true; + } + + // check if there is a $set or $unset that indicates something is an + // object rather than a scalar in the actual object where we saw $-operator + // NOTE: it is correct since we allow only scalars in $-operators + // Example: for selector {'a.b': {$gt: 5}} the modifier {'a.b.c':7} would + // definitely set the result to false as 'a.b' appears to be an object. + const expectedScalarIsObject = Object.keys(this._selector).some(path => { + if (!isOperatorObject(this._selector[path])) { + return false; + } + + return modifierPaths.some(modifierPath => + modifierPath.startsWith(`${path}.`) + ); + }); + + if (expectedScalarIsObject) { + return false; + } + + // See if we can apply the modifier on the ideally matching object. If it + // still matches the selector, then the modifier could have turned the real + // object in the database into something matching. + const matchingDocument = EJSON.clone(this.matchingDocument()); + + // The selector is too complex, anything can happen. + if (matchingDocument === null) { + return true; + } + + try { + LocalCollection._modify(matchingDocument, modifier); + } catch (error) { + // Couldn't set a property on a field which is a scalar or null in the + // selector. + // Example: + // real document: { 'a.b': 3 } + // selector: { 'a': 12 } + // converted selector (ideal document): { 'a': 12 } + // modifier: { $set: { 'a.b': 4 } } + // We don't know what real document was like but from the error raised by + // $set on a scalar field we can reason that the structure of real document + // is completely different. + if (error.name === 'MinimongoError' && error.setPropertyError) { + return false; + } + + throw error; + } + + return this.documentMatches(matchingDocument).result; +}; + +// Knows how to combine a mongo selector and a fields projection to a new fields +// projection taking into account active fields from the passed selector. +// @returns Object - projection object (same as fields option of mongo cursor) +Minimongo.Matcher.prototype.combineIntoProjection = function(projection) { + const selectorPaths = Minimongo._pathsElidingNumericKeys(this._getPaths()); + + // Special case for $where operator in the selector - projection should depend + // on all fields of the document. getSelectorPaths returns a list of paths + // selector depends on. If one of the paths is '' (empty string) representing + // the root or the whole document, complete projection should be returned. + if (selectorPaths.includes('')) { + return {}; + } + + return combineImportantPathsIntoProjection(selectorPaths, projection); +}; + +// Returns an object that would match the selector if possible or null if the +// selector is too complex for us to analyze +// { 'a.b': { ans: 42 }, 'foo.bar': null, 'foo.baz': "something" } +// => { a: { b: { ans: 42 } }, foo: { bar: null, baz: "something" } } +Minimongo.Matcher.prototype.matchingDocument = function() { + // check if it was computed before + if (this._matchingDocument !== undefined) { + return this._matchingDocument; + } + + // If the analysis of this selector is too hard for our implementation + // fallback to "YES" + let fallback = false; + + this._matchingDocument = pathsToTree( + this._getPaths(), + path => { + const valueSelector = this._selector[path]; + + if (isOperatorObject(valueSelector)) { + // if there is a strict equality, there is a good + // chance we can use one of those as "matching" + // dummy value + if (valueSelector.$eq) { + return valueSelector.$eq; + } + + if (valueSelector.$in) { + const matcher = new Minimongo.Matcher({placeholder: valueSelector}); + + // Return anything from $in that matches the whole selector for this + // path. If nothing matches, returns `undefined` as nothing can make + // this selector into `true`. + return valueSelector.$in.find(placeholder => + matcher.documentMatches({placeholder}).result + ); + } + + if (onlyContainsKeys(valueSelector, ['$gt', '$gte', '$lt', '$lte'])) { + let lowerBound = -Infinity; + let upperBound = Infinity; + + ['$lte', '$lt'].forEach(op => { + if (hasOwn.call(valueSelector, op) && + valueSelector[op] < upperBound) { + upperBound = valueSelector[op]; + } + }); + + ['$gte', '$gt'].forEach(op => { + if (hasOwn.call(valueSelector, op) && + valueSelector[op] > lowerBound) { + lowerBound = valueSelector[op]; + } + }); + + const middle = (lowerBound + upperBound) / 2; + const matcher = new Minimongo.Matcher({placeholder: valueSelector}); + + if (!matcher.documentMatches({placeholder: middle}).result && + (middle === lowerBound || middle === upperBound)) { + fallback = true; + } + + return middle; + } + + if (onlyContainsKeys(valueSelector, ['$nin', '$ne'])) { + // Since this._isSimple makes sure $nin and $ne are not combined with + // objects or arrays, we can confidently return an empty object as it + // never matches any scalar. + return {}; + } + + fallback = true; + } + + return this._selector[path]; + }, + x => x); + + if (fallback) { + this._matchingDocument = null; + } + + return this._matchingDocument; +}; + +// Minimongo.Sorter gets a similar method, which delegates to a Matcher it made +// for this exact purpose. +Minimongo.Sorter.prototype.affectedByModifier = function(modifier) { + return this._selectorForAffectedByModifier.affectedByModifier(modifier); +}; + +Minimongo.Sorter.prototype.combineIntoProjection = function(projection) { + return combineImportantPathsIntoProjection( + Minimongo._pathsElidingNumericKeys(this._getPaths()), + projection + ); +}; + +function combineImportantPathsIntoProjection(paths, projection) { + const details = projectionDetails(projection); + + // merge the paths to include + const tree = pathsToTree( + paths, + path => true, + (node, path, fullPath) => true, + details.tree + ); + const mergedProjection = treeToPaths(tree); + + if (details.including) { + // both selector and projection are pointing on fields to include + // so we can just return the merged tree + return mergedProjection; + } + + // selector is pointing at fields to include + // projection is pointing at fields to exclude + // make sure we don't exclude important paths + const mergedExclProjection = {}; + + Object.keys(mergedProjection).forEach(path => { + if (!mergedProjection[path]) { + mergedExclProjection[path] = false; + } + }); + + return mergedExclProjection; +} + +function getPaths(selector) { + return Object.keys(new Minimongo.Matcher(selector)._paths); + + // XXX remove it? + // return Object.keys(selector).map(k => { + // // we don't know how to handle $where because it can be anything + // if (k === '$where') { + // return ''; // matches everything + // } + + // // we branch from $or/$and/$nor operator + // if (['$or', '$and', '$nor'].includes(k)) { + // return selector[k].map(getPaths); + // } + + // // the value is a literal or some comparison operator + // return k; + // }) + // .reduce((a, b) => a.concat(b), []) + // .filter((a, b, c) => c.indexOf(a) === b); +} + +// A helper to ensure object has only certain keys +function onlyContainsKeys(obj, keys) { + return Object.keys(obj).every(k => keys.includes(k)); +} + +function pathHasNumericKeys(path) { + return path.split('.').some(isNumericKey); +} + +// Returns a set of key paths similar to +// { 'foo.bar': 1, 'a.b.c': 1 } +function treeToPaths(tree, prefix = '') { + const result = {}; + + Object.keys(tree).forEach(key => { + const value = tree[key]; + if (value === Object(value)) { + Object.assign(result, treeToPaths(value, `${prefix + key}.`)); + } else { + result[prefix + key] = value; + } + }); + + return result; +} diff --git a/packages/minimongo/minimongo_server_tests.js b/packages/minimongo/minimongo_server_tests.js deleted file mode 100644 index 38cc1476da7..00000000000 --- a/packages/minimongo/minimongo_server_tests.js +++ /dev/null @@ -1,571 +0,0 @@ -Tinytest.add("minimongo - modifier affects selector", function (test) { - function testSelectorPaths (sel, paths, desc) { - var matcher = new Minimongo.Matcher(sel); - test.equal(matcher._getPaths(), paths, desc); - } - - testSelectorPaths({ - foo: { - bar: 3, - baz: 42 - } - }, ['foo'], "literal"); - - testSelectorPaths({ - foo: 42, - bar: 33 - }, ['foo', 'bar'], "literal"); - - testSelectorPaths({ - foo: [ 'something' ], - bar: "asdf" - }, ['foo', 'bar'], "literal"); - - testSelectorPaths({ - a: { $lt: 3 }, - b: "you know, literal", - 'path.is.complicated': { $not: { $regex: 'acme.*corp' } } - }, ['a', 'b', 'path.is.complicated'], "literal + operators"); - - testSelectorPaths({ - $or: [{ 'a.b': 1 }, { 'a.b.c': { $lt: 22 } }, - {$and: [{ 'x.d': { $ne: 5, $gte: 433 } }, { 'a.b': 234 }]}] - }, ['a.b', 'a.b.c', 'x.d'], 'group operators + duplicates'); - - // When top-level value is an object, it is treated as a literal, - // so when you query col.find({ a: { foo: 1, bar: 2 } }) - // it doesn't mean you are looking for anything that has 'a.foo' to be 1 and - // 'a.bar' to be 2, instead you are looking for 'a' to be exatly that object - // with exatly that order of keys. { a: { foo: 1, bar: 2, baz: 3 } } wouldn't - // match it. That's why in this selector 'a' would be important key, not a.foo - // and a.bar. - testSelectorPaths({ - a: { - foo: 1, - bar: 2 - }, - 'b.c': { - literal: "object", - but: "we still observe any changes in 'b.c'" - } - }, ['a', 'b.c'], "literal object"); - - // Note that a and b do NOT end up in the path list, but x and y both do. - testSelectorPaths({ - $or: [ - {x: {$elemMatch: {a: 5}}}, - {y: {$elemMatch: {b: 7}}} - ] - }, ['x', 'y'], "$or and elemMatch"); - - function testSelectorAffectedByModifier (sel, mod, yes, desc) { - var matcher = new Minimongo.Matcher(sel); - test.equal(matcher.affectedByModifier(mod), yes, desc); - } - - function affected(sel, mod, desc) { - testSelectorAffectedByModifier(sel, mod, true, desc); - } - function notAffected(sel, mod, desc) { - testSelectorAffectedByModifier(sel, mod, false, desc); - } - - notAffected({ foo: 0 }, { $set: { bar: 1 } }, "simplest"); - affected({ foo: 0 }, { $set: { foo: 1 } }, "simplest"); - affected({ foo: 0 }, { $set: { 'foo.bar': 1 } }, "simplest"); - notAffected({ 'foo.bar': 0 }, { $set: { 'foo.baz': 1 } }, "simplest"); - affected({ 'foo.bar': 0 }, { $set: { 'foo.1': 1 } }, "simplest"); - affected({ 'foo.bar': 0 }, { $set: { 'foo.2.bar': 1 } }, "simplest"); - - notAffected({ 'foo': 0 }, { $set: { 'foobaz': 1 } }, "correct prefix check"); - notAffected({ 'foobar': 0 }, { $unset: { 'foo': 1 } }, "correct prefix check"); - notAffected({ 'foo.bar': 0 }, { $unset: { 'foob': 1 } }, "correct prefix check"); - - notAffected({ 'foo.Infinity.x': 0 }, { $unset: { 'foo.x': 1 } }, "we convert integer fields correctly"); - notAffected({ 'foo.1e3.x': 0 }, { $unset: { 'foo.x': 1 } }, "we convert integer fields correctly"); - - affected({ 'foo.3.bar': 0 }, { $set: { 'foo.3.bar': 1 } }, "observe for an array element"); - - notAffected({ 'foo.4.bar.baz': 0 }, { $unset: { 'foo.3.bar': 1 } }, "delicate work with numeric fields in selector"); - notAffected({ 'foo.4.bar.baz': 0 }, { $unset: { 'foo.bar': 1 } }, "delicate work with numeric fields in selector"); - affected({ 'foo.4.bar.baz': 0 }, { $unset: { 'foo.4.bar': 1 } }, "delicate work with numeric fields in selector"); - affected({ 'foo.bar.baz': 0 }, { $unset: { 'foo.3.bar': 1 } }, "delicate work with numeric fields in selector"); - - affected({ 'foo.0.bar': 0 }, { $set: { 'foo.0.0.bar': 1 } }, "delicate work with nested arrays and selectors by indecies"); - - affected({foo: {$elemMatch: {bar: 5}}}, {$set: {'foo.4.bar': 5}}, "$elemMatch"); -}); - -Tinytest.add("minimongo - selector and projection combination", function (test) { - function testSelProjectionComb (sel, proj, expected, desc) { - var matcher = new Minimongo.Matcher(sel); - test.equal(matcher.combineIntoProjection(proj), expected, desc); - } - - // Test with inclusive projection - testSelProjectionComb({ a: 1, b: 2 }, { b: 1, c: 1, d: 1 }, { a: true, b: true, c: true, d: true }, "simplest incl"); - testSelProjectionComb({ $or: [{ a: 1234, e: {$lt: 5} }], b: 2 }, { b: 1, c: 1, d: 1 }, { a: true, b: true, c: true, d: true, e: true }, "simplest incl, branching"); - testSelProjectionComb({ - 'a.b': { $lt: 3 }, - 'y.0': -1, - 'a.c': 15 - }, { - 'd': 1, - 'z': 1 - }, { - 'a.b': true, - 'y': true, - 'a.c': true, - 'd': true, - 'z': true - }, "multikey paths in selector - incl"); - - testSelProjectionComb({ - foo: 1234, - $and: [{ k: -1 }, { $or: [{ b: 15 }] }] - }, { - 'foo.bar': 1, - 'foo.zzz': 1, - 'b.asdf': 1 - }, { - foo: true, - b: true, - k: true - }, "multikey paths in fields - incl"); - - testSelProjectionComb({ - 'a.b.c': 123, - 'a.b.d': 321, - 'b.c.0': 111, - 'a.e': 12345 - }, { - 'a.b.z': 1, - 'a.b.d.g': 1, - 'c.c.c': 1 - }, { - 'a.b.c': true, - 'a.b.d': true, - 'a.b.z': true, - 'b.c': true, - 'a.e': true, - 'c.c.c': true - }, "multikey both paths - incl"); - - testSelProjectionComb({ - 'a.b.c.d': 123, - 'a.b1.c.d': 421, - 'a.b.c.e': 111 - }, { - 'a.b': 1 - }, { - 'a.b': true, - 'a.b1.c.d': true - }, "shadowing one another - incl"); - - testSelProjectionComb({ - 'a.b': 123, - 'foo.bar': false - }, { - 'a.b.c.d': 1, - 'foo': 1 - }, { - 'a.b': true, - 'foo': true - }, "shadowing one another - incl"); - - testSelProjectionComb({ - 'a.b.c': 1 - }, { - 'a.b.c': 1 - }, { - 'a.b.c': true - }, "same paths - incl"); - - testSelProjectionComb({ - 'x.4.y': 42, - 'z.0.1': 33 - }, { - 'x.x': 1 - }, { - 'x.x': true, - 'x.y': true, - 'z': true - }, "numbered keys in selector - incl"); - - testSelProjectionComb({ - 'a.b.c': 42, - $where: function () { return true; } - }, { - 'a.b': 1, - 'z.z': 1 - }, {}, "$where in the selector - incl"); - - testSelProjectionComb({ - $or: [ - {'a.b.c': 42}, - {$where: function () { return true; } } - ] - }, { - 'a.b': 1, - 'z.z': 1 - }, {}, "$where in the selector - incl"); - - // Test with exclusive projection - testSelProjectionComb({ a: 1, b: 2 }, { b: 0, c: 0, d: 0 }, { c: false, d: false }, "simplest excl"); - testSelProjectionComb({ $or: [{ a: 1234, e: {$lt: 5} }], b: 2 }, { b: 0, c: 0, d: 0 }, { c: false, d: false }, "simplest excl, branching"); - testSelProjectionComb({ - 'a.b': { $lt: 3 }, - 'y.0': -1, - 'a.c': 15 - }, { - 'd': 0, - 'z': 0 - }, { - d: false, - z: false - }, "multikey paths in selector - excl"); - - testSelProjectionComb({ - foo: 1234, - $and: [{ k: -1 }, { $or: [{ b: 15 }] }] - }, { - 'foo.bar': 0, - 'foo.zzz': 0, - 'b.asdf': 0 - }, { - }, "multikey paths in fields - excl"); - - testSelProjectionComb({ - 'a.b.c': 123, - 'a.b.d': 321, - 'b.c.0': 111, - 'a.e': 12345 - }, { - 'a.b.z': 0, - 'a.b.d.g': 0, - 'c.c.c': 0 - }, { - 'a.b.z': false, - 'c.c.c': false - }, "multikey both paths - excl"); - - testSelProjectionComb({ - 'a.b.c.d': 123, - 'a.b1.c.d': 421, - 'a.b.c.e': 111 - }, { - 'a.b': 0 - }, { - }, "shadowing one another - excl"); - - testSelProjectionComb({ - 'a.b': 123, - 'foo.bar': false - }, { - 'a.b.c.d': 0, - 'foo': 0 - }, { - }, "shadowing one another - excl"); - - testSelProjectionComb({ - 'a.b.c': 1 - }, { - 'a.b.c': 0 - }, { - }, "same paths - excl"); - - testSelProjectionComb({ - 'a.b': 123, - 'a.c.d': 222, - 'ddd': 123 - }, { - 'a.b': 0, - 'a.c.e': 0, - 'asdf': 0 - }, { - 'a.c.e': false, - 'asdf': false - }, "intercept the selector path - excl"); - - testSelProjectionComb({ - 'a.b.c': 14 - }, { - 'a.b.d': 0 - }, { - 'a.b.d': false - }, "different branches - excl"); - - testSelProjectionComb({ - 'a.b.c.d': "124", - 'foo.bar.baz.que': "some value" - }, { - 'a.b.c.d.e': 0, - 'foo.bar': 0 - }, { - }, "excl on incl paths - excl"); - - testSelProjectionComb({ - 'x.4.y': 42, - 'z.0.1': 33 - }, { - 'x.x': 0, - 'x.y': 0 - }, { - 'x.x': false, - }, "numbered keys in selector - excl"); - - testSelProjectionComb({ - 'a.b.c': 42, - $where: function () { return true; } - }, { - 'a.b': 0, - 'z.z': 0 - }, {}, "$where in the selector - excl"); - - testSelProjectionComb({ - $or: [ - {'a.b.c': 42}, - {$where: function () { return true; } } - ] - }, { - 'a.b': 0, - 'z.z': 0 - }, {}, "$where in the selector - excl"); - -}); - -Tinytest.add("minimongo - sorter and projection combination", function (test) { - function testSorterProjectionComb (sortSpec, proj, expected, desc) { - var sorter = new Minimongo.Sorter(sortSpec); - test.equal(sorter.combineIntoProjection(proj), expected, desc); - } - - // Test with inclusive projection - testSorterProjectionComb({ a: 1, b: 1 }, { b: 1, c: 1, d: 1 }, { a: true, b: true, c: true, d: true }, "simplest incl"); - testSorterProjectionComb({ a: 1, b: -1 }, { b: 1, c: 1, d: 1 }, { a: true, b: true, c: true, d: true }, "simplest incl"); - testSorterProjectionComb({ 'a.c': 1 }, { b: 1 }, { 'a.c': true, b: true }, "dot path incl"); - testSorterProjectionComb({ 'a.1.c': 1 }, { b: 1 }, { 'a.c': true, b: true }, "dot num path incl"); - testSorterProjectionComb({ 'a.1.c': 1 }, { b: 1, a: 1 }, { a: true, b: true }, "dot num path incl overlap"); - testSorterProjectionComb({ 'a.1.c': 1, 'a.2.b': -1 }, { b: 1 }, { 'a.c': true, 'a.b': true, b: true }, "dot num path incl"); - testSorterProjectionComb({ 'a.1.c': 1, 'a.2.b': -1 }, {}, {}, "dot num path with empty incl"); - - // Test with exclusive projection - testSorterProjectionComb({ a: 1, b: 1 }, { b: 0, c: 0, d: 0 }, { c: false, d: false }, "simplest excl"); - testSorterProjectionComb({ a: 1, b: -1 }, { b: 0, c: 0, d: 0 }, { c: false, d: false }, "simplest excl"); - testSorterProjectionComb({ 'a.c': 1 }, { b: 0 }, { b: false }, "dot path excl"); - testSorterProjectionComb({ 'a.1.c': 1 }, { b: 0 }, { b: false }, "dot num path excl"); - testSorterProjectionComb({ 'a.1.c': 1 }, { b: 0, a: 0 }, { b: false }, "dot num path excl overlap"); - testSorterProjectionComb({ 'a.1.c': 1, 'a.2.b': -1 }, { b: 0 }, { b: false }, "dot num path excl"); -}); - - -(function () { - // TODO: Tests for "can selector become true by modifier" are incomplete, - // absent or test the functionality of "not ideal" implementation (test checks - // that certain case always returns true as implementation is incomplete) - // - tests with $and/$or/$nor/$not branches (are absent) - // - more tests with arrays fields and numeric keys (incomplete and test "not - // ideal" implementation) - // - tests when numeric keys actually mean numeric keys, not array indexes - // (are absent) - // - tests with $-operators in the selector (are incomplete and test "not - // ideal" implementation) - // * gives up on $-operators with non-scalar values ({$ne: {x: 1}}) - // * analyses $in - // * analyses $nin/$ne - // * analyses $gt, $gte, $lt, $lte - // * gives up on a combination of $gt/$gte/$lt/$lte and $ne/$nin - // * doesn't support $eq properly - - var test = null; // set this global in the beginning of every test - // T - should return true - // F - should return false - var oneTest = function (sel, mod, expected, desc) { - var matcher = new Minimongo.Matcher(sel); - test.equal(matcher.canBecomeTrueByModifier(mod), expected, desc); - }; - function T (sel, mod, desc) { - oneTest(sel, mod, true, desc); - } - function F (sel, mod, desc) { - oneTest(sel, mod, false, desc); - } - - Tinytest.add("minimongo - can selector become true by modifier - literals (structured tests)", function (t) { - test = t; - - var selector = { - 'a.b.c': 2, - 'foo.bar': { - z: { y: 1 } - }, - 'foo.baz': [ {ans: 42}, "string", false, undefined ], - 'empty.field': null - }; - - T(selector, {$set:{ 'a.b.c': 2 }}); - F(selector, {$unset:{ 'a': 1 }}); - F(selector, {$unset:{ 'a.b': 1 }}); - F(selector, {$unset:{ 'a.b.c': 1 }}); - T(selector, {$set:{ 'a.b': { c: 2 } }}); - F(selector, {$set:{ 'a.b': {} }}); - T(selector, {$set:{ 'a.b': { c: 2, x: 5 } }}); - F(selector, {$set:{ 'a.b.c.k': 3 }}); - F(selector, {$set:{ 'a.b.c.k': {} }}); - - F(selector, {$unset:{ 'foo': 1 }}); - F(selector, {$unset:{ 'foo.bar': 1 }}); - F(selector, {$unset:{ 'foo.bar.z': 1 }}); - F(selector, {$unset:{ 'foo.bar.z.y': 1 }}); - F(selector, {$set:{ 'foo.bar.x': 1 }}); - F(selector, {$set:{ 'foo.bar': {} }}); - F(selector, {$set:{ 'foo.bar': 3 }}); - T(selector, {$set:{ 'foo.bar': { z: { y: 1 } } }}); - T(selector, {$set:{ 'foo.bar.z': { y: 1 } }}); - T(selector, {$set:{ 'foo.bar.z.y': 1 }}); - - F(selector, {$set:{ 'empty.field': {} }}); - T(selector, {$set:{ 'empty': {} }}); - T(selector, {$set:{ 'empty.field': null }}); - T(selector, {$set:{ 'empty.field': undefined }}); - F(selector, {$set:{ 'empty.field.a': 3 }}); - }); - - Tinytest.add("minimongo - can selector become true by modifier - literals (adhoc tests)", function (t) { - test = t; - T({x:1}, {$set:{x:1}}, "simple set scalar"); - T({x:"a"}, {$set:{x:"a"}}, "simple set scalar"); - T({x:false}, {$set:{x:false}}, "simple set scalar"); - F({x:true}, {$set:{x:false}}, "simple set scalar"); - F({x:2}, {$set:{x:3}}, "simple set scalar"); - - F({'foo.bar.baz': 1, x:1}, {$unset:{'foo.bar.baz': 1}, $set:{x:1}}, "simple unset of the interesting path"); - F({'foo.bar.baz': 1, x:1}, {$unset:{'foo.bar': 1}, $set:{x:1}}, "simple unset of the interesting path prefix"); - F({'foo.bar.baz': 1, x:1}, {$unset:{'foo': 1}, $set:{x:1}}, "simple unset of the interesting path prefix"); - F({'foo.bar.baz': 1}, {$unset:{'foo.baz': 1}}, "simple unset of the interesting path prefix"); - F({'foo.bar.baz': 1}, {$unset:{'foo.bar.bar': 1}}, "simple unset of the interesting path prefix"); - }); - - Tinytest.add("minimongo - can selector become true by modifier - regexps", function (t) { - test = t; - - // Regexp - T({ 'foo.bar': /^[0-9]+$/i }, { $set: {'foo.bar': '01233'} }, "set of regexp"); - // XXX this test should be False, should be fixed within improved implementation - T({ 'foo.bar': /^[0-9]+$/i, x: 1 }, { $set: {'foo.bar': '0a1233', x: 1} }, "set of regexp"); - // XXX this test should be False, should be fixed within improved implementation - T({ 'foo.bar': /^[0-9]+$/i, x: 1 }, { $unset: {'foo.bar': 1}, $set: { x: 1 } }, "unset of regexp"); - T({ 'foo.bar': /^[0-9]+$/i, x: 1 }, { $set: { x: 1 } }, "don't touch regexp"); - }); - - Tinytest.add("minimongo - can selector become true by modifier - undefined/null", function (t) { - test = t; - // Nulls / Undefined - T({ 'foo.bar': null }, {$set:{'foo.bar': null}}, "set of null looking for null"); - T({ 'foo.bar': null }, {$set:{'foo.bar': undefined}}, "set of undefined looking for null"); - T({ 'foo.bar': undefined }, {$set:{'foo.bar': null}}, "set of null looking for undefined"); - T({ 'foo.bar': undefined }, {$set:{'foo.bar': undefined}}, "set of undefined looking for undefined"); - T({ 'foo.bar': null }, {$set:{'foo': null}}, "set of null of parent path looking for null"); - F({ 'foo.bar': null }, {$set:{'foo.bar.baz': null}}, "set of null of different path looking for null"); - T({ 'foo.bar': null }, { $unset: { 'foo': 1 } }, "unset the parent"); - T({ 'foo.bar': null }, { $unset: { 'foo.bar': 1 } }, "unset tracked path"); - T({ 'foo.bar': null }, { $set: { 'foo': 3 } }, "set the parent"); - T({ 'foo.bar': null }, { $set: { 'foo': {baz:1} } }, "set the parent"); - - }); - - Tinytest.add("minimongo - can selector become true by modifier - literals with arrays", function (t) { - test = t; - // These tests are incomplete and in theory they all should return true as we - // don't support any case with numeric fields yet. - T({'a.1.b': 1, x:1}, {$unset:{'a.1.b': 1}, $set:{x:1}}, "unset of array element's field with exactly the same index as selector"); - F({'a.2.b': 1}, {$unset:{'a.1.b': 1}}, "unset of array element's field with different index as selector"); - // This is false, because if you are looking for array but in reality it is an - // object, it just can't get to true. - F({'a.2.b': 1}, {$unset:{'a.b': 1}}, "unset of field while selector is looking for index"); - T({ 'foo.bar': null }, {$set:{'foo.1.bar': null}}, "set array's element's field to null looking for null"); - T({ 'foo.bar': null }, {$set:{'foo.0.bar': 1, 'foo.1.bar': null}}, "set array's element's field to null looking for null"); - // This is false, because there may remain other array elements that match - // but we modified this test as we don't support this case yet - T({'a.b': 1}, {$unset:{'a.1.b': 1}}, "unset of array element's field"); - }); - - Tinytest.add("minimongo - can selector become true by modifier - set an object literal whose fields are selected", function (t) { - test = t; - T({ 'a.b.c': 1 }, { $set: { 'a.b': { c: 1 } } }, "a simple scalar selector and simple set"); - F({ 'a.b.c': 1 }, { $set: { 'a.b': { c: 2 } } }, "a simple scalar selector and simple set to false"); - F({ 'a.b.c': 1 }, { $set: { 'a.b': { d: 1 } } }, "a simple scalar selector and simple set a wrong literal"); - F({ 'a.b.c': 1 }, { $set: { 'a.b': 222 } }, "a simple scalar selector and simple set a wrong type"); - }); - - Tinytest.add("minimongo - can selector become true by modifier - $-scalar selectors and simple tests", function (t) { - test = t; - T({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { c: 4 } } }, "nested $lt"); - F({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { c: 5 } } }, "nested $lt"); - F({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { c: 6 } } }, "nested $lt"); - F({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b.d': 7 } }, "nested $lt, the change doesn't matter"); - F({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { d: 7 } } }, "nested $lt, the key disappears"); - T({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { d: 7, c: -1 } } }, "nested $lt"); - F({ a: { $lt: 10, $gt: 3 } }, { $unset: { a: 1 } }, "unset $lt"); - T({ a: { $lt: 10, $gt: 3 } }, { $set: { a: 4 } }, "set between x and y"); - F({ a: { $lt: 10, $gt: 3 } }, { $set: { a: 3 } }, "set between x and y"); - F({ a: { $lt: 10, $gt: 3 } }, { $set: { a: 10 } }, "set between x and y"); - F({ a: { $gt: 10, $lt: 3 } }, { $set: { a: 9 } }, "impossible statement"); - T({ a: { $lte: 10, $gte: 3 } }, { $set: { a: 3 } }, "set between x and y"); - T({ a: { $lte: 10, $gte: 3 } }, { $set: { a: 10 } }, "set between x and y"); - F({ a: { $lte: 10, $gte: 3 } }, { $set: { a: -10 } }, "set between x and y"); - T({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 } }, { $set: { a: 4 } }, "set between x and y"); - F({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 } }, { $set: { a: 3 } }, "set between x and y"); - F({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 } }, { $set: { a: 10 } }, "set between x and y"); - F({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 } }, { $set: { a: Infinity } }, "set between x and y"); - T({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 }, x: 1 }, { $set: { x: 1 } }, "set between x and y - dummy"); - F({ a: { $lte: 10, $gte: 13, $gt: 3, $lt: 9 }, x: 1 }, { $set: { x: 1 } }, "set between x and y - dummy - impossible"); - F({ a: { $lte: 10 } }, { $set: { a: Infinity } }, "Infinity <= 10?"); - T({ a: { $lte: 10 } }, { $set: { a: -Infinity } }, "-Infinity <= 10?"); - // XXX is this sufficient? - T({ a: { $gt: 9.99999999999999, $lt: 10 }, x: 1 }, { $set: { x: 1 } }, "very close $gt and $lt"); - // XXX this test should be F, but since it is so hard to be precise in - // floating point math, the current implementation falls back to T - T({ a: { $gt: 9.999999999999999, $lt: 10 }, x: 1 }, { $set: { x: 1 } }, "very close $gt and $lt"); - T({ a: { $eq: 5 } }, { $set: { a: 5 } }, "set of $eq"); - T({ a: { $eq: 5 }, b: { $eq: 7 } }, { $set: { a: 5 } }, "set of $eq with other $eq"); - F({ a: { $eq: 5 } }, { $set: { a: 4 } }, "set below of $eq"); - F({ a: { $eq: 5 } }, { $set: { a: 6 } }, "set above of $eq"); - T({ a: { $ne: 5 } }, { $unset: { a: 1 } }, "unset of $ne"); - T({ a: { $ne: 5 } }, { $set: { a: 1 } }, "set of $ne"); - T({ a: { $ne: "some string" }, x: 1 }, { $set: { x: 1 } }, "$ne dummy"); - T({ a: { $ne: true }, x: 1 }, { $set: { x: 1 } }, "$ne dummy"); - T({ a: { $ne: false }, x: 1 }, { $set: { x: 1 } }, "$ne dummy"); - T({ a: { $ne: null }, x: 1 }, { $set: { x: 1 } }, "$ne dummy"); - T({ a: { $ne: Infinity }, x: 1 }, { $set: { x: 1 } }, "$ne dummy"); - T({ a: { $ne: 5 } }, { $set: { a: -10 } }, "set of $ne"); - T({ a: { $in: [1, 3, 5, 7] } }, { $set: { a: 5 } }, "$in checks"); - F({ a: { $in: [1, 3, 5, 7] } }, { $set: { a: -5 } }, "$in checks"); - T({ a: { $in: [1, 3, 5, 7], $gt: 6 }, x: 1 }, { $set: { x: 1 } }, "$in combination with $gt"); - F({ a: { $lte: 10, $gte: 3 } }, { $set: { 'a.b': -10 } }, "sel between x and y, set its subfield"); - F({ b: { $in: [1, 3, 5, 7] } }, { $set: { 'b.c': 2 } }, "sel $in, set subfield"); - T({ b: { $in: [1, 3, 5, 7] } }, { $set: { 'bd.c': 2, b: 3 } }, "sel $in, set similar subfield"); - F({ 'b.c': { $in: [1, 3, 5, 7] } }, { $set: { b: 2 } }, "sel subfield of set scalar"); - // If modifier tries to set a sub-field of a path expected to be a scalar. - F({ 'a.b': { $gt: 5, $lt: 7}, x: 1 }, { $set: { 'a.b.c': 3, x: 1 } }, "set sub-field of $gt,$lt operator (scalar expected)"); - F({ 'a.b': { $gt: 5, $lt: 7}, x: 1 }, { $set: { x: 1 }, $unset: { 'a.b.c': 1 } }, "unset sub-field of $gt,$lt operator (scalar expected)"); - }); - - Tinytest.add("minimongo - can selector become true by modifier - $-nonscalar selectors and simple tests", function (t) { - test = t; - T({ a: { $eq: { x: 5 } } }, { $set: { 'a.x': 5 } }, "set of $eq"); - // XXX this test should be F, but it is not implemented yet - T({ a: { $eq: { x: 5 } } }, { $set: { 'a.x': 4 } }, "set of $eq"); - // XXX this test should be F, but it is not implemented yet - T({ a: { $eq: { x: 5 } } }, { $set: { 'a.y': 4 } }, "set of $eq"); - T({ a: { $ne: { x: 5 } } }, { $set: { 'a.x': 3 } }, "set of $ne"); - // XXX this test should be F, but it is not implemented yet - T({ a: { $ne: { x: 5 } } }, { $set: { 'a.x': 5 } }, "set of $ne"); - T({ a: { $in: [{ b: 1 }, { b: 3 }] } }, { $set: { a: { b: 3 } } }, "$in checks"); - // XXX this test should be F, but it is not implemented yet - T({ a: { $in: [{ b: 1 }, { b: 3 }] } }, { $set: { a: { v: 3 } } }, "$in checks"); - T({ a: { $ne: { a: 2 } }, x: 1 }, { $set: { x: 1 } }, "$ne dummy"); - // XXX this test should be F, but it is not implemented yet - T({ a: { $ne: { a: 2 } } }, { $set: { a: { a: 2 } } }, "$ne object"); - }); -})(); diff --git a/packages/minimongo/minimongo_tests.js b/packages/minimongo/minimongo_tests.js index 5fa47d11d3a..9f16b1c9a33 100644 --- a/packages/minimongo/minimongo_tests.js +++ b/packages/minimongo/minimongo_tests.js @@ -1,3846 +1,60 @@ +Tinytest.add('minimongo - wrapTransform', test => { + const wrap = LocalCollection.wrapTransform; + + // Transforming no function gives falsey. + test.isFalse(wrap(undefined)); + test.isFalse(wrap(null)); + + // It's OK if you don't change the ID. + const validTransform = doc => { + delete doc.x; + doc.y = 42; + doc.z = () => 43; + return doc; + }; + const transformed = wrap(validTransform)({_id: 'asdf', x: 54}); + test.equal(Object.keys(transformed), ['_id', 'y', 'z']); + test.equal(transformed.y, 42); + test.equal(transformed.z(), 43); + + // Ensure that ObjectIDs work (even if the _ids in question are not ===-equal) + const oid1 = new MongoID.ObjectID(); + const oid2 = new MongoID.ObjectID(oid1.toHexString()); + test.equal(wrap(() => ({ + _id: oid2, + }))({_id: oid1}), + {_id: oid2}); -// Hack to make LocalCollection generate ObjectIDs by default. -LocalCollection._useOID = true; - -// assert that f is a strcmp-style comparison function that puts -// 'values' in the provided order - -var assert_ordering = function (test, f, values) { - for (var i = 0; i < values.length; i++) { - var x = f(values[i], values[i]); - if (x !== 0) { - // XXX super janky - test.fail({type: "minimongo-ordering", - message: "value doesn't order as equal to itself", - value: JSON.stringify(values[i]), - should_be_zero_but_got: JSON.stringify(x)}); - } - if (i + 1 < values.length) { - var less = values[i]; - var more = values[i + 1]; - var x = f(less, more); - if (!(x < 0)) { - // XXX super janky - test.fail({type: "minimongo-ordering", - message: "ordering test failed", - first: JSON.stringify(less), - second: JSON.stringify(more), - should_be_negative_but_got: JSON.stringify(x)}); - } - x = f(more, less); - if (!(x > 0)) { - // XXX super janky - test.fail({type: "minimongo-ordering", - message: "ordering test failed", - first: JSON.stringify(less), - second: JSON.stringify(more), - should_be_positive_but_got: JSON.stringify(x)}); - } - } - } -}; - -var log_callbacks = function (operations) { - return { - addedAt: function (obj, idx, before) { - delete obj._id; - operations.push(EJSON.clone(['added', obj, idx, before])); - }, - changedAt: function (obj, old_obj, at) { - delete obj._id; - delete old_obj._id; - operations.push(EJSON.clone(['changed', obj, at, old_obj])); - }, - movedTo: function (obj, old_at, new_at, before) { - delete obj._id; - operations.push(EJSON.clone(['moved', obj, old_at, new_at, before])); - }, - removedAt: function (old_obj, at) { - var id = old_obj._id; - delete old_obj._id; - operations.push(EJSON.clone(['removed', id, at, old_obj])); - } - }; -}; - -// XXX test shared structure in all MM entrypoints -Tinytest.add("minimongo - basics", function (test) { - var c = new LocalCollection(), - fluffyKitten_id, - count; - - fluffyKitten_id = c.insert({type: "kitten", name: "fluffy"}); - c.insert({type: "kitten", name: "snookums"}); - c.insert({type: "cryptographer", name: "alice"}); - c.insert({type: "cryptographer", name: "bob"}); - c.insert({type: "cryptographer", name: "cara"}); - test.equal(c.find().count(), 5); - test.equal(c.find({type: "kitten"}).count(), 2); - test.equal(c.find({type: "cryptographer"}).count(), 3); - test.length(c.find({type: "kitten"}).fetch(), 2); - test.length(c.find({type: "cryptographer"}).fetch(), 3); - test.equal(fluffyKitten_id, c.findOne({type: "kitten", name: "fluffy"})._id); - - c.remove({name: "cara"}); - test.equal(c.find().count(), 4); - test.equal(c.find({type: "kitten"}).count(), 2); - test.equal(c.find({type: "cryptographer"}).count(), 2); - test.length(c.find({type: "kitten"}).fetch(), 2); - test.length(c.find({type: "cryptographer"}).fetch(), 2); - - count = c.update({name: "snookums"}, {$set: {type: "cryptographer"}}); - test.equal(count, 1); - test.equal(c.find().count(), 4); - test.equal(c.find({type: "kitten"}).count(), 1); - test.equal(c.find({type: "cryptographer"}).count(), 3); - test.length(c.find({type: "kitten"}).fetch(), 1); - test.length(c.find({type: "cryptographer"}).fetch(), 3); - - c.remove(null); - c.remove(false); - c.remove(undefined); - test.equal(c.find().count(), 4); - - c.remove({_id: null}); - c.remove({_id: false}); - c.remove({_id: undefined}); - count = c.remove(); - test.equal(count, 0); - test.equal(c.find().count(), 4); - - count = c.remove({}); - test.equal(count, 4); - test.equal(c.find().count(), 0); - - c.insert({_id: 1, name: "strawberry", tags: ["fruit", "red", "squishy"]}); - c.insert({_id: 2, name: "apple", tags: ["fruit", "red", "hard"]}); - c.insert({_id: 3, name: "rose", tags: ["flower", "red", "squishy"]}); - - test.equal(c.find({tags: "flower"}).count(), 1); - test.equal(c.find({tags: "fruit"}).count(), 2); - test.equal(c.find({tags: "red"}).count(), 3); - test.length(c.find({tags: "flower"}).fetch(), 1); - test.length(c.find({tags: "fruit"}).fetch(), 2); - test.length(c.find({tags: "red"}).fetch(), 3); - - test.equal(c.findOne(1).name, "strawberry"); - test.equal(c.findOne(2).name, "apple"); - test.equal(c.findOne(3).name, "rose"); - test.equal(c.findOne(4), undefined); - test.equal(c.findOne("abc"), undefined); - test.equal(c.findOne(undefined), undefined); - - test.equal(c.find(1).count(), 1); - test.equal(c.find(4).count(), 0); - test.equal(c.find("abc").count(), 0); - test.equal(c.find(undefined).count(), 0); - test.equal(c.find().count(), 3); - test.equal(c.find(1, {skip: 1}).count(), 0); - test.equal(c.find({_id: 1}, {skip: 1}).count(), 0); - test.equal(c.find({}, {skip: 1}).count(), 2); - test.equal(c.find({}, {skip: 2}).count(), 1); - test.equal(c.find({}, {limit: 2}).count(), 2); - test.equal(c.find({}, {limit: 1}).count(), 1); - test.equal(c.find({}, {skip: 1, limit: 1}).count(), 1); - test.equal(c.find({tags: "fruit"}, {skip: 1}).count(), 1); - test.equal(c.find({tags: "fruit"}, {limit: 1}).count(), 1); - test.equal(c.find({tags: "fruit"}, {skip: 1, limit: 1}).count(), 1); - test.equal(c.find(1, {sort: ['_id','desc'], skip: 1}).count(), 0); - test.equal(c.find({_id: 1}, {sort: ['_id','desc'], skip: 1}).count(), 0); - test.equal(c.find({}, {sort: ['_id','desc'], skip: 1}).count(), 2); - test.equal(c.find({}, {sort: ['_id','desc'], skip: 2}).count(), 1); - test.equal(c.find({}, {sort: ['_id','desc'], limit: 2}).count(), 2); - test.equal(c.find({}, {sort: ['_id','desc'], limit: 1}).count(), 1); - test.equal(c.find({}, {sort: ['_id','desc'], skip: 1, limit: 1}).count(), 1); - test.equal(c.find({tags: "fruit"}, {sort: ['_id','desc'], skip: 1}).count(), 1); - test.equal(c.find({tags: "fruit"}, {sort: ['_id','desc'], limit: 1}).count(), 1); - test.equal(c.find({tags: "fruit"}, {sort: ['_id','desc'], skip: 1, limit: 1}).count(), 1); - - // Regression test for #455. - c.insert({foo: {bar: 'baz'}}); - test.equal(c.find({foo: {bam: 'baz'}}).count(), 0); - test.equal(c.find({foo: {bar: 'baz'}}).count(), 1); - - // Regression test for #5301 - c.remove({}); - c.insert({ a: 'a', b: 'b' }); - const noop = () => null; - test.equal(c.find({ a: noop }).count(), 1); - test.equal(c.find({ a: 'a', b: noop }).count(), 1); - test.equal(c.find({ c: noop }).count(), 1); - test.equal(c.find({ a: noop, c: 'c' }).count(), 0); -}); - -Tinytest.add("minimongo - error - no options", function (test) { - try { - throw MinimongoError("Not fun to have errors"); - } catch (e) { - test.equal(e.message, "Not fun to have errors"); - } -}); - -Tinytest.add("minimongo - error - with field", function (test) { - try { - throw MinimongoError("Cats are no fun", { field: "mice" }); - } catch (e) { - test.equal(e.message, "Cats are no fun for field 'mice'"); - } -}); - -Tinytest.add("minimongo - cursors", function (test) { - var c = new LocalCollection(); - var res; - - for (var i = 0; i < 20; i++) - c.insert({i: i}); - - var q = c.find(); - test.equal(q.count(), 20); - - // fetch - res = q.fetch(); - test.length(res, 20); - for (var i = 0; i < 20; i++) { - test.equal(res[i].i, i); - } - // call it again, it still works - test.length(q.fetch(), 20); - - // forEach - var count = 0; - var context = {}; - q.forEach(function (obj, i, cursor) { - test.equal(obj.i, count++); - test.equal(obj.i, i); - test.isTrue(context === this); - test.isTrue(cursor === q); - }, context); - test.equal(count, 20); - // call it again, it still works - test.length(q.fetch(), 20); - - // map - res = q.map(function (obj, i, cursor) { - test.equal(obj.i, i); - test.isTrue(context === this); - test.isTrue(cursor === q); - return obj.i * 2; - }, context); - test.length(res, 20); - for (var i = 0; i < 20; i++) - test.equal(res[i], i * 2); - // call it again, it still works - test.length(q.fetch(), 20); - - // findOne (and no rewind first) - test.equal(c.findOne({i: 0}).i, 0); - test.equal(c.findOne({i: 1}).i, 1); - var id = c.findOne({i: 2})._id; - test.equal(c.findOne(id).i, 2); -}); - -Tinytest.add("minimongo - transform", function (test) { - var c = new LocalCollection; - c.insert({}); - // transform functions must return objects - var invalidTransform = function (doc) { return doc._id; }; - test.throws(function () { - c.findOne({}, {transform: invalidTransform}); - }); - - // transformed documents get _id field transplanted if not present - var transformWithoutId = function (doc) { return _.omit(doc, '_id'); }; - test.equal(c.findOne({}, {transform: transformWithoutId})._id, - c.findOne()._id); -}); - -Tinytest.add("minimongo - misc", function (test) { - // deepcopy - var a = {a: [1, 2, 3], b: "x", c: true, d: {x: 12, y: [12]}, - f: null, g: new Date()}; - var b = EJSON.clone(a); - test.equal(a, b); - test.isTrue(LocalCollection._f._equal(a, b)); - a.a.push(4); - test.length(b.a, 3); - a.c = false; - test.isTrue(b.c); - b.d.z = 15; - a.d.z = 14; - test.equal(b.d.z, 15); - a.d.y.push(88); - test.length(b.d.y, 1); - test.equal(a.g, b.g); - b.g.setDate(b.g.getDate() + 1); - test.notEqual(a.g, b.g); - - a = {x: function () {}}; - b = EJSON.clone(a); - a.x.a = 14; - test.equal(b.x.a, 14); // just to document current behavior -}); - -Tinytest.add("minimongo - lookup", function (test) { - var lookupA = MinimongoTest.makeLookupFunction('a'); - test.equal(lookupA({}), [{value: undefined}]); - test.equal(lookupA({a: 1}), [{value: 1}]); - test.equal(lookupA({a: [1]}), [{value: [1]}]); - - var lookupAX = MinimongoTest.makeLookupFunction('a.x'); - test.equal(lookupAX({a: {x: 1}}), [{value: 1}]); - test.equal(lookupAX({a: {x: [1]}}), [{value: [1]}]); - test.equal(lookupAX({a: 5}), [{value: undefined}]); - test.equal(lookupAX({a: [{x: 1}, {x: [2]}, {y: 3}]}), - [{value: 1, arrayIndices: [0]}, - {value: [2], arrayIndices: [1]}, - {value: undefined, arrayIndices: [2]}]); - - var lookupA0X = MinimongoTest.makeLookupFunction('a.0.x'); - test.equal(lookupA0X({a: [{x: 1}]}), [ - // From interpreting '0' as "0th array element". - {value: 1, arrayIndices: [0, 'x']}, - // From interpreting '0' as "after branching in the array, look in the - // object {x:1} for a field named 0". - {value: undefined, arrayIndices: [0]}]); - test.equal(lookupA0X({a: [{x: [1]}]}), [ - {value: [1], arrayIndices: [0, 'x']}, - {value: undefined, arrayIndices: [0]}]); - test.equal(lookupA0X({a: 5}), [{value: undefined}]); - test.equal(lookupA0X({a: [{x: 1}, {x: [2]}, {y: 3}]}), [ - // From interpreting '0' as "0th array element". - {value: 1, arrayIndices: [0, 'x']}, - // From interpreting '0' as "after branching in the array, look in the - // object {x:1} for a field named 0". - {value: undefined, arrayIndices: [0]}, - {value: undefined, arrayIndices: [1]}, - {value: undefined, arrayIndices: [2]} - ]); - - test.equal( - MinimongoTest.makeLookupFunction('w.x.0.z')({ - w: [{x: [{z: 5}]}]}), [ - // From interpreting '0' as "0th array element". - {value: 5, arrayIndices: [0, 0, 'x']}, - // From interpreting '0' as "after branching in the array, look in the - // object {z:5} for a field named "0". - {value: undefined, arrayIndices: [0, 0]} - ]); -}); - -Tinytest.add("minimongo - selector_compiler", function (test) { - var matches = function (shouldMatch, selector, doc) { - var doesMatch = new Minimongo.Matcher(selector).documentMatches(doc).result; - if (doesMatch != shouldMatch) { - // XXX super janky - test.fail({message: "minimongo match failure: document " + - (shouldMatch ? "should match, but doesn't" : - "shouldn't match, but does"), - selector: JSON.stringify(selector), - document: JSON.stringify(doc) - }); - } - }; - - var match = _.bind(matches, null, true); - var nomatch = _.bind(matches, null, false); - - // XXX blog post about what I learned while writing these tests (weird - // mongo edge cases) - - // empty selectors - match({}, {}); - match({}, {a: 12}); - - // scalars - match(1, {_id: 1, a: 'foo'}); - nomatch(1, {_id: 2, a: 'foo'}); - match('a', {_id: 'a', a: 'foo'}); - nomatch('a', {_id: 'b', a: 'foo'}); - - // safety - nomatch(undefined, {}); - nomatch(undefined, {_id: 'foo'}); - nomatch(false, {_id: 'foo'}); - nomatch(null, {_id: 'foo'}); - nomatch({_id: undefined}, {_id: 'foo'}); - nomatch({_id: false}, {_id: 'foo'}); - nomatch({_id: null}, {_id: 'foo'}); - - // matching one or more keys - nomatch({a: 12}, {}); - match({a: 12}, {a: 12}); - match({a: 12}, {a: 12, b: 13}); - match({a: 12, b: 13}, {a: 12, b: 13}); - match({a: 12, b: 13}, {a: 12, b: 13, c: 14}); - nomatch({a: 12, b: 13, c: 14}, {a: 12, b: 13}); - nomatch({a: 12, b: 13}, {b: 13, c: 14}); - - match({a: 12}, {a: [12]}); - match({a: 12}, {a: [11, 12, 13]}); - nomatch({a: 12}, {a: [11, 13]}); - match({a: 12, b: 13}, {a: [11, 12, 13], b: [13, 14, 15]}); - nomatch({a: 12, b: 13}, {a: [11, 12, 13], b: [14, 15]}); - - // dates - var date1 = new Date; - var date2 = new Date(date1.getTime() + 1000); - match({a: date1}, {a: date1}); - nomatch({a: date1}, {a: date2}); - - - // arrays - match({a: [1,2]}, {a: [1, 2]}); - match({a: [1,2]}, {a: [[1, 2]]}); - match({a: [1,2]}, {a: [[3, 4], [1, 2]]}); - nomatch({a: [1,2]}, {a: [3, 4]}); - nomatch({a: [1,2]}, {a: [[[1, 2]]]}); - - // literal documents - match({a: {b: 12}}, {a: {b: 12}}); - nomatch({a: {b: 12, c: 13}}, {a: {b: 12}}); - nomatch({a: {b: 12}}, {a: {b: 12, c: 13}}); - match({a: {b: 12, c: 13}}, {a: {b: 12, c: 13}}); - nomatch({a: {b: 12, c: 13}}, {a: {c: 13, b: 12}}); // tested on mongodb - nomatch({a: {}}, {a: {b: 12}}); - nomatch({a: {b:12}}, {a: {}}); - match( - {a: {b: 12, c: [13, true, false, 2.2, "a", null, {d: 14}]}}, - {a: {b: 12, c: [13, true, false, 2.2, "a", null, {d: 14}]}}); - match({a: {b: 12}}, {a: {b: 12}, k: 99}); - - match({a: {b: 12}}, {a: [{b: 12}]}); - nomatch({a: {b: 12}}, {a: [[{b: 12}]]}); - match({a: {b: 12}}, {a: [{b: 11}, {b: 12}, {b: 13}]}); - nomatch({a: {b: 12}}, {a: [{b: 11}, {b: 12, c: 20}, {b: 13}]}); - nomatch({a: {b: 12, c: 20}}, {a: [{b: 11}, {b: 12}, {c: 20}]}); - match({a: {b: 12, c: 20}}, {a: [{b: 11}, {b: 12, c: 20}, {b: 13}]}); - - // null - match({a: null}, {a: null}); - match({a: null}, {b: 12}); - nomatch({a: null}, {a: 12}); - match({a: null}, {a: [1, 2, null, 3]}); // tested on mongodb - nomatch({a: null}, {a: [1, 2, {}, 3]}); // tested on mongodb - - // order comparisons: $lt, $gt, $lte, $gte - match({a: {$lt: 10}}, {a: 9}); - nomatch({a: {$lt: 10}}, {a: 10}); - nomatch({a: {$lt: 10}}, {a: 11}); - - match({a: {$gt: 10}}, {a: 11}); - nomatch({a: {$gt: 10}}, {a: 10}); - nomatch({a: {$gt: 10}}, {a: 9}); - - match({a: {$lte: 10}}, {a: 9}); - match({a: {$lte: 10}}, {a: 10}); - nomatch({a: {$lte: 10}}, {a: 11}); - - match({a: {$gte: 10}}, {a: 11}); - match({a: {$gte: 10}}, {a: 10}); - nomatch({a: {$gte: 10}}, {a: 9}); - - match({a: {$lt: 10}}, {a: [11, 9, 12]}); - nomatch({a: {$lt: 10}}, {a: [11, 12]}); - - // (there's a full suite of ordering test elsewhere) - nomatch({a: {$lt: "null"}}, {a: null}); - match({a: {$lt: {x: [2, 3, 4]}}}, {a: {x: [1, 3, 4]}}); - match({a: {$gt: {x: [2, 3, 4]}}}, {a: {x: [3, 3, 4]}}); - nomatch({a: {$gt: {x: [2, 3, 4]}}}, {a: {x: [1, 3, 4]}}); - nomatch({a: {$gt: {x: [2, 3, 4]}}}, {a: {x: [2, 3, 4]}}); - nomatch({a: {$lt: {x: [2, 3, 4]}}}, {a: {x: [2, 3, 4]}}); - match({a: {$gte: {x: [2, 3, 4]}}}, {a: {x: [2, 3, 4]}}); - match({a: {$lte: {x: [2, 3, 4]}}}, {a: {x: [2, 3, 4]}}); - - nomatch({a: {$gt: [2, 3]}}, {a: [1, 2]}); // tested against mongodb - - // composition of two qualifiers - nomatch({a: {$lt: 11, $gt: 9}}, {a: 8}); - nomatch({a: {$lt: 11, $gt: 9}}, {a: 9}); - match({a: {$lt: 11, $gt: 9}}, {a: 10}); - nomatch({a: {$lt: 11, $gt: 9}}, {a: 11}); - nomatch({a: {$lt: 11, $gt: 9}}, {a: 12}); - - match({a: {$lt: 11, $gt: 9}}, {a: [8, 9, 10, 11, 12]}); - match({a: {$lt: 11, $gt: 9}}, {a: [8, 9, 11, 12]}); // tested against mongodb - - // $all - match({a: {$all: [1, 2]}}, {a: [1, 2]}); - nomatch({a: {$all: [1, 2, 3]}}, {a: [1, 2]}); - match({a: {$all: [1, 2]}}, {a: [3, 2, 1]}); - match({a: {$all: [1, "x"]}}, {a: [3, "x", 1]}); - nomatch({a: {$all: ['2']}}, {a: 2}); - nomatch({a: {$all: [2]}}, {a: '2'}); - match({a: {$all: [[1, 2], [1, 3]]}}, {a: [[1, 3], [1, 2], [1, 4]]}); - nomatch({a: {$all: [[1, 2], [1, 3]]}}, {a: [[1, 4], [1, 2], [1, 4]]}); - match({a: {$all: [2, 2]}}, {a: [2]}); // tested against mongodb - nomatch({a: {$all: [2, 3]}}, {a: [2, 2]}); - - nomatch({a: {$all: [1, 2]}}, {a: [[1, 2]]}); // tested against mongodb - nomatch({a: {$all: [1, 2]}}, {}); // tested against mongodb, field doesn't exist - nomatch({a: {$all: [1, 2]}}, {a: {foo: 'bar'}}); // tested against mongodb, field is not an object - nomatch({a: {$all: []}}, {a: []}); - nomatch({a: {$all: []}}, {a: [5]}); - match({a: {$all: [/i/, /e/i]}}, {a: ["foo", "bEr", "biz"]}); - nomatch({a: {$all: [/i/, /e/i]}}, {a: ["foo", "bar", "biz"]}); - match({a: {$all: [{b: 3}]}}, {a: [{b: 3}]}); - // Members of $all other than regexps are *equality matches*, not document - // matches. - nomatch({a: {$all: [{b: 3}]}}, {a: [{b: 3, k: 4}]}); - test.throws(function () { - match({a: {$all: [{$gt: 4}]}}, {}); - }); - - // $exists - match({a: {$exists: true}}, {a: 12}); - nomatch({a: {$exists: true}}, {b: 12}); - nomatch({a: {$exists: false}}, {a: 12}); - match({a: {$exists: false}}, {b: 12}); - - match({a: {$exists: true}}, {a: []}); - nomatch({a: {$exists: true}}, {b: []}); - nomatch({a: {$exists: false}}, {a: []}); - match({a: {$exists: false}}, {b: []}); - - match({a: {$exists: true}}, {a: [1]}); - nomatch({a: {$exists: true}}, {b: [1]}); - nomatch({a: {$exists: false}}, {a: [1]}); - match({a: {$exists: false}}, {b: [1]}); - - match({a: {$exists: 1}}, {a: 5}); - match({a: {$exists: 0}}, {b: 5}); - - nomatch({'a.x':{$exists: false}}, {a: [{}, {x: 5}]}); - match({'a.x':{$exists: true}}, {a: [{}, {x: 5}]}); - match({'a.x':{$exists: true}}, {a: [{}, {x: 5}]}); - match({'a.x':{$exists: true}}, {a: {x: []}}); - match({'a.x':{$exists: true}}, {a: {x: null}}); - - // $mod - match({a: {$mod: [10, 1]}}, {a: 11}); - nomatch({a: {$mod: [10, 1]}}, {a: 12}); - match({a: {$mod: [10, 1]}}, {a: [10, 11, 12]}); - nomatch({a: {$mod: [10, 1]}}, {a: [10, 12]}); - _.each([ - 5, - [10], - [10, 1, 2], - "foo", - {bar: 1}, - [] - ], function (badMod) { - test.throws(function () { - match({a: {$mod: badMod}}, {a: 11}); - }); - }); - - // $eq - nomatch({a: {$eq: 1}}, {a: 2}); - match({a: {$eq: 2}}, {a: 2}); - nomatch({a: {$eq: [1]}}, {a: [2]}); - - match({a: {$eq: [1, 2]}}, {a: [1, 2]}); - match({a: {$eq: 1}}, {a: [1, 2]}); - match({a: {$eq: 2}}, {a: [1, 2]}); - nomatch({a: {$eq: 3}}, {a: [1, 2]}); - match({'a.b': {$eq: 1}}, {a: [{b: 1}, {b: 2}]}); - match({'a.b': {$eq: 2}}, {a: [{b: 1}, {b: 2}]}); - nomatch({'a.b': {$eq: 3}}, {a: [{b: 1}, {b: 2}]}); - - match({a: {$eq: {x: 1}}}, {a: {x: 1}}); - nomatch({a: {$eq: {x: 1}}}, {a: {x: 2}}); - nomatch({a: {$eq: {x: 1}}}, {a: {x: 1, y: 2}}); - - // $ne - match({a: {$ne: 1}}, {a: 2}); - nomatch({a: {$ne: 2}}, {a: 2}); - match({a: {$ne: [1]}}, {a: [2]}); - - nomatch({a: {$ne: [1, 2]}}, {a: [1, 2]}); // all tested against mongodb - nomatch({a: {$ne: 1}}, {a: [1, 2]}); - nomatch({a: {$ne: 2}}, {a: [1, 2]}); - match({a: {$ne: 3}}, {a: [1, 2]}); - nomatch({'a.b': {$ne: 1}}, {a: [{b: 1}, {b: 2}]}); - nomatch({'a.b': {$ne: 2}}, {a: [{b: 1}, {b: 2}]}); - match({'a.b': {$ne: 3}}, {a: [{b: 1}, {b: 2}]}); - - nomatch({a: {$ne: {x: 1}}}, {a: {x: 1}}); - match({a: {$ne: {x: 1}}}, {a: {x: 2}}); - match({a: {$ne: {x: 1}}}, {a: {x: 1, y: 2}}); - - // This query means: All 'a.b' must be non-5, and some 'a.b' must be >6. - match({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 10}]}); - nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 4}]}); - nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 5}]}); - nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 10}, {b: 5}]}); - // Should work the same if the branch is at the bottom. - match({a: {$ne: 5, $gt: 6}}, {a: [2, 10]}); - nomatch({a: {$ne: 5, $gt: 6}}, {a: [2, 4]}); - nomatch({a: {$ne: 5, $gt: 6}}, {a: [2, 5]}); - nomatch({a: {$ne: 5, $gt: 6}}, {a: [10, 5]}); - - // $in - match({a: {$in: [1, 2, 3]}}, {a: 2}); - nomatch({a: {$in: [1, 2, 3]}}, {a: 4}); - match({a: {$in: [[1], [2], [3]]}}, {a: [2]}); - nomatch({a: {$in: [[1], [2], [3]]}}, {a: [4]}); - match({a: {$in: [{b: 1}, {b: 2}, {b: 3}]}}, {a: {b: 2}}); - nomatch({a: {$in: [{b: 1}, {b: 2}, {b: 3}]}}, {a: {b: 4}}); - - match({a: {$in: [1, 2, 3]}}, {a: [2]}); // tested against mongodb - match({a: {$in: [{x: 1}, {x: 2}, {x: 3}]}}, {a: [{x: 2}]}); - match({a: {$in: [1, 2, 3]}}, {a: [4, 2]}); - nomatch({a: {$in: [1, 2, 3]}}, {a: [4]}); - - match({a: {$in: ['x', /foo/i]}}, {a: 'x'}); - match({a: {$in: ['x', /foo/i]}}, {a: 'fOo'}); - match({a: {$in: ['x', /foo/i]}}, {a: ['f', 'fOo']}); - nomatch({a: {$in: ['x', /foo/i]}}, {a: ['f', 'fOx']}); - - match({a: {$in: [1, null]}}, {}); - match({'a.b': {$in: [1, null]}}, {}); - match({'a.b': {$in: [1, null]}}, {a: {}}); - match({'a.b': {$in: [1, null]}}, {a: {b: null}}); - nomatch({'a.b': {$in: [1, null]}}, {a: {b: 5}}); - nomatch({'a.b': {$in: [1]}}, {a: {b: null}}); - nomatch({'a.b': {$in: [1]}}, {a: {}}); - nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}]}); - match({'a.b': {$in: [1, null]}}, {a: [{b: 5}, {}]}); - nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}, []]}); - nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}, 5]}); - - // $nin - nomatch({a: {$nin: [1, 2, 3]}}, {a: 2}); - match({a: {$nin: [1, 2, 3]}}, {a: 4}); - nomatch({a: {$nin: [[1], [2], [3]]}}, {a: [2]}); - match({a: {$nin: [[1], [2], [3]]}}, {a: [4]}); - nomatch({a: {$nin: [{b: 1}, {b: 2}, {b: 3}]}}, {a: {b: 2}}); - match({a: {$nin: [{b: 1}, {b: 2}, {b: 3}]}}, {a: {b: 4}}); - - nomatch({a: {$nin: [1, 2, 3]}}, {a: [2]}); // tested against mongodb - nomatch({a: {$nin: [{x: 1}, {x: 2}, {x: 3}]}}, {a: [{x: 2}]}); - nomatch({a: {$nin: [1, 2, 3]}}, {a: [4, 2]}); - nomatch({'a.b': {$nin: [1, 2, 3]}}, {a: [{b:4}, {b:2}]}); - match({a: {$nin: [1, 2, 3]}}, {a: [4]}); - match({'a.b': {$nin: [1, 2, 3]}}, {a: [{b:4}]}); - - nomatch({a: {$nin: ['x', /foo/i]}}, {a: 'x'}); - nomatch({a: {$nin: ['x', /foo/i]}}, {a: 'fOo'}); - nomatch({a: {$nin: ['x', /foo/i]}}, {a: ['f', 'fOo']}); - match({a: {$nin: ['x', /foo/i]}}, {a: ['f', 'fOx']}); - - nomatch({a: {$nin: [1, null]}}, {}); - nomatch({'a.b': {$nin: [1, null]}}, {}); - nomatch({'a.b': {$nin: [1, null]}}, {a: {}}); - nomatch({'a.b': {$nin: [1, null]}}, {a: {b: null}}); - match({'a.b': {$nin: [1, null]}}, {a: {b: 5}}); - match({'a.b': {$nin: [1]}}, {a: {b: null}}); - match({'a.b': {$nin: [1]}}, {a: {}}); - match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}]}); - nomatch({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, {}]}); - match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, []]}); - match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, 5]}); - - // $size - match({a: {$size: 0}}, {a: []}); - match({a: {$size: 1}}, {a: [2]}); - match({a: {$size: 2}}, {a: [2, 2]}); - nomatch({a: {$size: 0}}, {a: [2]}); - nomatch({a: {$size: 1}}, {a: []}); - nomatch({a: {$size: 1}}, {a: [2, 2]}); - nomatch({a: {$size: 0}}, {a: "2"}); - nomatch({a: {$size: 1}}, {a: "2"}); - nomatch({a: {$size: 2}}, {a: "2"}); - - nomatch({a: {$size: 2}}, {a: [[2,2]]}); // tested against mongodb - - - // $bitsAllClear - number - match({a: {$bitsAllClear: [0,1,2,3]}}, {a: 0}); - match({a: {$bitsAllClear: [0,1,2,3]}}, {a: 0b10000}); - nomatch({a: {$bitsAllClear: [0,1,2,3]}}, {a: 0b1}); - nomatch({a: {$bitsAllClear: [0,1,2,3]}}, {a: 0b10}); - nomatch({a: {$bitsAllClear: [0,1,2,3]}}, {a: 0b100}); - nomatch({a: {$bitsAllClear: [0,1,2,3]}}, {a: 0b1000}); - - // $bitsAllClear - buffer - match({a: {$bitsAllClear: new Uint8Array([3])}}, {a: new Uint8Array([4])}); - match({a: {$bitsAllClear: new Uint8Array([0, 1])}}, {a: new Uint8Array([255])}); // 256 should not be set for 255. - match({a: {$bitsAllClear: new Uint8Array([3])}}, {a: 4 }); - - match({a: {$bitsAllClear: new Uint8Array([3])}}, {a: 0 }); - - // $bitsAllSet - number - match({a: {$bitsAllSet: [0,1,2,3]}}, {a: 0b1111}); - nomatch({a: {$bitsAllSet: [0,1,2,3]}}, {a: 0b111}); - nomatch({a: {$bitsAllSet: [0,1,2,3]}}, {a: 256}); - nomatch({a: {$bitsAllSet: [0,1,2,3]}}, {a: 50000}); - match({a: {$bitsAllSet: [0,1,2]}}, {a: 15}); - match({a: {$bitsAllSet: [0, 12]}}, {a: 0b1000000000001}); - nomatch({a: {$bitsAllSet: [0, 12]}}, {a: 0b1000000000000}); - nomatch({a: {$bitsAllSet: [0, 12]}}, {a: 0b1}); - - // $bitsAllSet - buffer - match({a: {$bitsAllSet: new Uint8Array([3])}}, {a: new Uint8Array([3])}); - match({a: {$bitsAllSet: new Uint8Array([7])}}, {a: new Uint8Array([15])}); - match({a: {$bitsAllSet: new Uint8Array([3])}}, {a: 3 }); - - // $bitsAnySet - number - match({a: {$bitsAnySet: [0,1,2,3]}}, {a: 0b1}); - match({a: {$bitsAnySet: [0,1,2,3]}}, {a: 0b10}); - match({a: {$bitsAnySet: [0,1,2,3]}}, {a: 0b100}); - match({a: {$bitsAnySet: [0,1,2,3]}}, {a: 0b1000}); - match({a: {$bitsAnySet: [4]}}, {a: 0b10000}); - nomatch({a: {$bitsAnySet: [0,1,2,3]}}, {a: 0b10000}); - nomatch({a: {$bitsAnySet: [0,1,2,3]}}, {a: 0}); - - // $bitsAnySet - buffer - match({a: {$bitsAnySet: new Uint8Array([3])}}, {a: new Uint8Array([7])}); - match({a: {$bitsAnySet: new Uint8Array([15])}}, {a: new Uint8Array([7])}); - match({a: {$bitsAnySet: new Uint8Array([3])}}, {a: 1 }); - - // $bitsAnyClear - number - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0}); - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b1}); - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b10}); - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b100}); - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b1000}); - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b10000}); - nomatch({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b1111}); - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b111}); - nomatch({a: {$bitsAnyClear: [0,1,2]}}, {a: 0b111}); - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b11}); - nomatch({a: {$bitsAnyClear: [0,1]}}, {a: 0b11}); - match({a: {$bitsAnyClear: [0,1,2,3]}}, {a: 0b1}); - nomatch({a: {$bitsAnyClear: [0]}}, {a: 0b1}); - nomatch({a: {$bitsAnyClear: [4]}}, {a: 0b10000}); - - // $bitsAnyClear - buffer - match({a: {$bitsAnyClear: new Uint8Array([8])}}, {a: new Uint8Array([7])}); - match({a: {$bitsAnyClear: new Uint8Array([1])}}, {a: new Uint8Array([0])}); - match({a: {$bitsAnyClear: new Uint8Array([1])}}, {a: 4 }); - - // taken from: https://github.com/mongodb/mongo/blob/master/jstests/core/bittest.js - var c = new LocalCollection; - function matchCount(query, count) { - const matches = c.find(query).count() - if (matches !== count) { - test.fail({message: "minimongo match count failure: matched " + matches + " times, but should match " + count + " times", - query: JSON.stringify(query), - count: JSON.stringify(count) - }); - } - } - - // Tests on numbers. - - c.insert({a: 0}) - c.insert({a: 1}) - c.insert({a: 54}) - c.insert({a: 88}) - c.insert({a: 255}) - - // Tests with bitmask. - matchCount({a: {$bitsAllSet: 0}}, 5) - matchCount({a: {$bitsAllSet: 1}}, 2) - matchCount({a: {$bitsAllSet: 16}}, 3) - matchCount({a: {$bitsAllSet: 54}}, 2) - matchCount({a: {$bitsAllSet: 55}}, 1) - matchCount({a: {$bitsAllSet: 88}}, 2) - matchCount({a: {$bitsAllSet: 255}}, 1) - matchCount({a: {$bitsAllClear: 0}}, 5) - matchCount({a: {$bitsAllClear: 1}}, 3) - matchCount({a: {$bitsAllClear: 16}}, 2) - matchCount({a: {$bitsAllClear: 129}}, 3) - matchCount({a: {$bitsAllClear: 255}}, 1) - matchCount({a: {$bitsAnySet: 0}}, 0) - matchCount({a: {$bitsAnySet: 9}}, 3) - matchCount({a: {$bitsAnySet: 255}}, 4) - matchCount({a: {$bitsAnyClear: 0}}, 0) - matchCount({a: {$bitsAnyClear: 18}}, 3) - matchCount({a: {$bitsAnyClear: 24}}, 3) - matchCount({a: {$bitsAnyClear: 255}}, 4) - - // Tests with array of bit positions. - matchCount({a: {$bitsAllSet: []}}, 5) - matchCount({a: {$bitsAllSet: [0]}}, 2) - matchCount({a: {$bitsAllSet: [4]}}, 3) - matchCount({a: {$bitsAllSet: [1, 2, 4, 5]}}, 2) - matchCount({a: {$bitsAllSet: [0, 1, 2, 4, 5]}}, 1) - matchCount({a: {$bitsAllSet: [3, 4, 6]}}, 2) - matchCount({a: {$bitsAllSet: [0, 1, 2, 3, 4, 5, 6, 7]}}, 1) - matchCount({a: {$bitsAllClear: []}}, 5) - matchCount({a: {$bitsAllClear: [0]}}, 3) - matchCount({a: {$bitsAllClear: [4]}}, 2) - matchCount({a: {$bitsAllClear: [1, 7]}}, 3) - matchCount({a: {$bitsAllClear: [0, 1, 2, 3, 4, 5, 6, 7]}}, 1) - matchCount({a: {$bitsAnySet: []}}, 0) - matchCount({a: {$bitsAnySet: [1, 3]}}, 3) - matchCount({a: {$bitsAnySet: [0, 1, 2, 3, 4, 5, 6, 7]}}, 4) - matchCount({a: {$bitsAnyClear: []}}, 0) - matchCount({a: {$bitsAnyClear: [1, 4]}}, 3) - matchCount({a: {$bitsAnyClear: [3, 4]}}, 3) - matchCount({a: {$bitsAnyClear: [0, 1, 2, 3, 4, 5, 6, 7]}}, 4) - - // Tests with multiple predicates. - matchCount({a: {$bitsAllSet: 54, $bitsAllClear: 201}}, 1) - - // Tests on negative numbers - - c.remove({}) - c.insert({a: -0}) - c.insert({a: -1}) - c.insert({a: -54}) - - // Tests with bitmask. - matchCount({a: {$bitsAllSet: 0}}, 3) - matchCount({a: {$bitsAllSet: 2}}, 2) - matchCount({a: {$bitsAllSet: 127}}, 1) - matchCount({a: {$bitsAllSet: 74}}, 2) - matchCount({a: {$bitsAllClear: 0}}, 3) - matchCount({a: {$bitsAllClear: 53}}, 2) - matchCount({a: {$bitsAllClear: 127}}, 1) - matchCount({a: {$bitsAnySet: 0}}, 0) - matchCount({a: {$bitsAnySet: 2}}, 2) - matchCount({a: {$bitsAnySet: 127}}, 2) - matchCount({a: {$bitsAnyClear: 0}}, 0) - matchCount({a: {$bitsAnyClear: 53}}, 2) - matchCount({a: {$bitsAnyClear: 127}}, 2) - - // Tests with array of bit positions. - var allPositions = [] - for (var i = 0; i < 64; i++) { - allPositions.push(i) - } - - matchCount({a: {$bitsAllSet: []}}, 3) - matchCount({a: {$bitsAllSet: [1]}}, 2) - matchCount({a: {$bitsAllSet: allPositions}}, 1) - matchCount({a: {$bitsAllSet: [1, 7, 6, 3, 100]}}, 2) - matchCount({a: {$bitsAllClear: []}}, 3) - matchCount({a: {$bitsAllClear: [5, 4, 2, 0]}}, 2) - matchCount({a: {$bitsAllClear: allPositions}}, 1) - matchCount({a: {$bitsAnySet: []}}, 0) - matchCount({a: {$bitsAnySet: [1]}}, 2) - matchCount({a: {$bitsAnySet: allPositions}}, 2) - matchCount({a: {$bitsAnyClear: []}}, 0) - matchCount({a: {$bitsAnyClear: [0, 2, 4, 5, 100]}}, 2) - matchCount({a: {$bitsAnyClear: allPositions}}, 2) - - // Tests with multiple predicates. - matchCount({a: {$bitsAllSet: 74, $bitsAllClear: 53}}, 1) - - // Tests on BinData. - - c.remove({}) - c.insert({a: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}) - c.insert({a: EJSON.parse('{"$binary": "AANgAAAAAAAAAAAAAAAAAAAAAAAA"}')}) - c.insert({a: EJSON.parse('{"$binary": "JANgqwetkqwklEWRbWERKKJREtbq"}')}) - c.insert({a: EJSON.parse('{"$binary": "////////////////////////////"}')}) - - // Tests with binary string bitmask. - matchCount({a: {$bitsAllSet: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 4) - matchCount({a: {$bitsAllSet: EJSON.parse('{"$binary": "AANgAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 3) - matchCount({a: {$bitsAllSet: EJSON.parse('{"$binary": "JANgqwetkqwklEWRbWERKKJREtbq"}')}}, 2) - matchCount({a: {$bitsAllSet: EJSON.parse('{"$binary": "////////////////////////////"}')}}, 1) - matchCount({a: {$bitsAllClear: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 4) - matchCount({a: {$bitsAllClear: EJSON.parse('{"$binary": "AAyfAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 3) - matchCount({a: {$bitsAllClear: EJSON.parse('{"$binary": "JAyfqwetkqwklEWRbWERKKJREtbq"}')}}, 2) - matchCount({a: {$bitsAllClear: EJSON.parse('{"$binary": "////////////////////////////"}')}}, 1) - matchCount({a: {$bitsAnySet: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 0) - matchCount({a: {$bitsAnySet: EJSON.parse('{"$binary": "AAyfAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 1) - matchCount({a: {$bitsAnySet: EJSON.parse('{"$binary": "JAyfqwetkqwklEWRbWERKKJREtbq"}')}}, 2) - matchCount({a: {$bitsAnySet: EJSON.parse('{"$binary": "////////////////////////////"}')}}, 3) - matchCount({a: {$bitsAnyClear: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 0) - matchCount({a: {$bitsAnyClear: EJSON.parse('{"$binary": "AANgAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 1) - matchCount({a: {$bitsAnyClear: EJSON.parse('{"$binary": "JANgqwetkqwklEWRbWERKKJREtbq"}')}}, 2) - matchCount({a: {$bitsAnyClear: EJSON.parse('{"$binary": "////////////////////////////"}')}}, 3) - - // Tests with multiple predicates. - matchCount({ - a: { - $bitsAllSet: EJSON.parse('{"$binary": "AANgAAAAAAAAAAAAAAAAAAAAAAAA"}'), - $bitsAllClear: EJSON.parse('{"$binary": "//yf////////////////////////"}') - } - }, 1) - - c.remove({}) - - nomatch({a: {$bitsAllSet: 1}}, {a: false}) - nomatch({a: {$bitsAllSet: 1}}, {a: NaN}) - nomatch({a: {$bitsAllSet: 1}}, {a: Infinity}) - nomatch({a: {$bitsAllSet: 1}}, {a: null}) - nomatch({a: {$bitsAllSet: 1}}, {a: 'asdf'}) - nomatch({a: {$bitsAllSet: 1}}, {a: ['a', 'b']}) - nomatch({a: {$bitsAllSet: 1}}, {a: {foo: 'bar'}}) - nomatch({a: {$bitsAllSet: 1}}, {a: 1.2}) - nomatch({a: {$bitsAllSet: 1}}, {a: "1"}) - - _.each([ - false, - NaN, - Infinity, - null, - 'asdf', - ['a', 'b'], - {foo: 'bar'}, - 1.2, - "1", - [0, -1] - ], function (badValue) { - test.throws(function () { - match({a: {$bitsAllSet: badValue}}, {a: 42}); - }); - }); - - // $type - match({a: {$type: 1}}, {a: 1.1}); - match({a: {$type: 1}}, {a: 1}); - nomatch({a: {$type: 1}}, {a: "1"}); - match({a: {$type: 2}}, {a: "1"}); - nomatch({a: {$type: 2}}, {a: 1}); - match({a: {$type: 3}}, {a: {}}); - match({a: {$type: 3}}, {a: {b: 2}}); - nomatch({a: {$type: 3}}, {a: []}); - nomatch({a: {$type: 3}}, {a: [1]}); - nomatch({a: {$type: 3}}, {a: null}); - match({a: {$type: 5}}, {a: EJSON.newBinary(0)}); - match({a: {$type: 5}}, {a: EJSON.newBinary(4)}); - nomatch({a: {$type: 5}}, {a: []}); - nomatch({a: {$type: 5}}, {a: [42]}); - match({a: {$type: 7}}, {a: new MongoID.ObjectID()}); - nomatch({a: {$type: 7}}, {a: "1234567890abcd1234567890"}); - match({a: {$type: 8}}, {a: true}); - match({a: {$type: 8}}, {a: false}); - nomatch({a: {$type: 8}}, {a: "true"}); - nomatch({a: {$type: 8}}, {a: 0}); - nomatch({a: {$type: 8}}, {a: null}); - nomatch({a: {$type: 8}}, {a: ''}); - nomatch({a: {$type: 8}}, {}); - match({a: {$type: 9}}, {a: (new Date)}); - nomatch({a: {$type: 9}}, {a: +(new Date)}); - match({a: {$type: 10}}, {a: null}); - nomatch({a: {$type: 10}}, {a: false}); - nomatch({a: {$type: 10}}, {a: ''}); - nomatch({a: {$type: 10}}, {a: 0}); - nomatch({a: {$type: 10}}, {}); - match({a: {$type: 11}}, {a: /x/}); - nomatch({a: {$type: 11}}, {a: 'x'}); - nomatch({a: {$type: 11}}, {}); - - // The normal rule for {$type:4} (4 means array) is that it NOT good enough to - // just have an array that's the leaf that matches the path. (An array inside - // that array is good, though.) - nomatch({a: {$type: 4}}, {a: []}); - nomatch({a: {$type: 4}}, {a: [1]}); // tested against mongodb - match({a: {$type: 1}}, {a: [1]}); - nomatch({a: {$type: 2}}, {a: [1]}); - match({a: {$type: 1}}, {a: ["1", 1]}); - match({a: {$type: 2}}, {a: ["1", 1]}); - nomatch({a: {$type: 3}}, {a: ["1", 1]}); - nomatch({a: {$type: 4}}, {a: ["1", 1]}); - nomatch({a: {$type: 1}}, {a: ["1", []]}); - match({a: {$type: 2}}, {a: ["1", []]}); - match({a: {$type: 4}}, {a: ["1", []]}); // tested against mongodb - // An exception to the normal rule is that an array found via numeric index is - // examined itself, and its elements are not. - match({'a.0': {$type: 4}}, {a: [[0]]}); - nomatch({'a.0': {$type: 1}}, {a: [[0]]}); - - // regular expressions - match({a: /a/}, {a: 'cat'}); - nomatch({a: /a/}, {a: 'cut'}); - nomatch({a: /a/}, {a: 'CAT'}); - match({a: /a/i}, {a: 'CAT'}); - match({a: /a/}, {a: ['foo', 'bar']}); // search within array... - nomatch({a: /,/}, {a: ['foo', 'bar']}); // but not by stringifying - match({a: {$regex: 'a'}}, {a: ['foo', 'bar']}); - nomatch({a: {$regex: ','}}, {a: ['foo', 'bar']}); - match({a: {$regex: /a/}}, {a: 'cat'}); - nomatch({a: {$regex: /a/}}, {a: 'cut'}); - nomatch({a: {$regex: /a/}}, {a: 'CAT'}); - match({a: {$regex: /a/i}}, {a: 'CAT'}); - match({a: {$regex: /a/, $options: 'i'}}, {a: 'CAT'}); // tested - match({a: {$regex: /a/i, $options: 'i'}}, {a: 'CAT'}); // tested - nomatch({a: {$regex: /a/i, $options: ''}}, {a: 'CAT'}); // tested - match({a: {$regex: 'a'}}, {a: 'cat'}); - nomatch({a: {$regex: 'a'}}, {a: 'cut'}); - nomatch({a: {$regex: 'a'}}, {a: 'CAT'}); - match({a: {$regex: 'a', $options: 'i'}}, {a: 'CAT'}); - match({a: {$regex: '', $options: 'i'}}, {a: 'foo'}); - nomatch({a: {$regex: '', $options: 'i'}}, {}); - nomatch({a: {$regex: '', $options: 'i'}}, {a: 5}); - nomatch({a: /undefined/}, {}); - nomatch({a: {$regex: 'undefined'}}, {}); - nomatch({a: /xxx/}, {}); - nomatch({a: {$regex: 'xxx'}}, {}); - - // GitHub issue #2817: - // Regexps with a global flag ('g') keep a state when tested against the same - // string. Selector shouldn't return different result for similar documents - // because of this state. - var reusedRegexp = /sh/ig; - match({a: reusedRegexp}, {a: 'Shorts'}); - match({a: reusedRegexp}, {a: 'Shorts'}); - match({a: reusedRegexp}, {a: 'Shorts'}); - - match({a: {$regex: reusedRegexp}}, {a: 'Shorts'}); - match({a: {$regex: reusedRegexp}}, {a: 'Shorts'}); - match({a: {$regex: reusedRegexp}}, {a: 'Shorts'}); - - test.throws(function () { - match({a: {$options: 'i'}}, {a: 12}); - }); - - match({a: /a/}, {a: ['dog', 'cat']}); - nomatch({a: /a/}, {a: ['dog', 'puppy']}); - - // we don't support regexps in minimongo very well (eg, there's no EJSON - // encoding so it won't go over the wire), but run these tests anyway - match({a: /a/}, {a: /a/}); - match({a: /a/}, {a: ['x', /a/]}); - nomatch({a: /a/}, {a: /a/i}); - nomatch({a: /a/m}, {a: /a/}); - nomatch({a: /a/}, {a: /b/}); - nomatch({a: /5/}, {a: 5}); - nomatch({a: /t/}, {a: true}); - match({a: /m/i}, {a: ['x', 'xM']}); - - test.throws(function () { - match({a: {$regex: /a/, $options: 'x'}}, {a: 'cat'}); - }); - test.throws(function () { - match({a: {$regex: /a/, $options: 's'}}, {a: 'cat'}); - }); - - // $not - match({x: {$not: {$gt: 7}}}, {x: 6}); - nomatch({x: {$not: {$gt: 7}}}, {x: 8}); - match({x: {$not: {$lt: 10, $gt: 7}}}, {x: 11}); - nomatch({x: {$not: {$lt: 10, $gt: 7}}}, {x: 9}); - match({x: {$not: {$lt: 10, $gt: 7}}}, {x: 6}); - - match({x: {$not: {$gt: 7}}}, {x: [2, 3, 4]}); - match({'x.y': {$not: {$gt: 7}}}, {x: [{y:2}, {y:3}, {y:4}]}); - nomatch({x: {$not: {$gt: 7}}}, {x: [2, 3, 4, 10]}); - nomatch({'x.y': {$not: {$gt: 7}}}, {x: [{y:2}, {y:3}, {y:4}, {y:10}]}); - - match({x: {$not: /a/}}, {x: "dog"}); - nomatch({x: {$not: /a/}}, {x: "cat"}); - match({x: {$not: /a/}}, {x: ["dog", "puppy"]}); - nomatch({x: {$not: /a/}}, {x: ["kitten", "cat"]}); - - // dotted keypaths: bare values - match({"a.b": 1}, {a: {b: 1}}); - nomatch({"a.b": 1}, {a: {b: 2}}); - match({"a.b": [1,2,3]}, {a: {b: [1,2,3]}}); - nomatch({"a.b": [1,2,3]}, {a: {b: [4]}}); - match({"a.b": /a/}, {a: {b: "cat"}}); - nomatch({"a.b": /a/}, {a: {b: "dog"}}); - match({"a.b.c": null}, {}); - match({"a.b.c": null}, {a: 1}); - match({"a.b": null}, {a: 1}); - match({"a.b.c": null}, {a: {b: 4}}); - - // dotted keypaths, nulls, numeric indices, arrays - nomatch({"a.b": null}, {a: [1]}); - match({"a.b": []}, {a: {b: []}}); - var big = {a: [{b: 1}, 2, {}, {b: [3, 4]}]}; - match({"a.b": 1}, big); - match({"a.b": [3, 4]}, big); - match({"a.b": 3}, big); - match({"a.b": 4}, big); - match({"a.b": null}, big); // matches on slot 2 - match({'a.1': 8}, {a: [7, 8, 9]}); - nomatch({'a.1': 7}, {a: [7, 8, 9]}); - nomatch({'a.1': null}, {a: [7, 8, 9]}); - match({'a.1': [8, 9]}, {a: [7, [8, 9]]}); - nomatch({'a.1': 6}, {a: [[6, 7], [8, 9]]}); - nomatch({'a.1': 7}, {a: [[6, 7], [8, 9]]}); - nomatch({'a.1': 8}, {a: [[6, 7], [8, 9]]}); - nomatch({'a.1': 9}, {a: [[6, 7], [8, 9]]}); - match({"a.1": 2}, {a: [0, {1: 2}, 3]}); - match({"a.1": {1: 2}}, {a: [0, {1: 2}, 3]}); - match({"x.1.y": 8}, {x: [7, {y: 8}, 9]}); - // comes from trying '1' as key in the plain object - match({"x.1.y": null}, {x: [7, {y: 8}, 9]}); - match({"a.1.b": 9}, {a: [7, {b: 9}, {1: {b: 'foo'}}]}); - match({"a.1.b": 'foo'}, {a: [7, {b: 9}, {1: {b: 'foo'}}]}); - match({"a.1.b": null}, {a: [7, {b: 9}, {1: {b: 'foo'}}]}); - match({"a.1.b": 2}, {a: [1, [{b: 2}], 3]}); - nomatch({"a.1.b": null}, {a: [1, [{b: 2}], 3]}); - // this is new behavior in mongo 2.5 - nomatch({"a.0.b": null}, {a: [5]}); - match({"a.1": 4}, {a: [{1: 4}, 5]}); - match({"a.1": 5}, {a: [{1: 4}, 5]}); - nomatch({"a.1": null}, {a: [{1: 4}, 5]}); - match({"a.1.foo": 4}, {a: [{1: {foo: 4}}, {foo: 5}]}); - match({"a.1.foo": 5}, {a: [{1: {foo: 4}}, {foo: 5}]}); - match({"a.1.foo": null}, {a: [{1: {foo: 4}}, {foo: 5}]}); - - // trying to access a dotted field that is undefined at some point - // down the chain - nomatch({"a.b": 1}, {x: 2}); - nomatch({"a.b.c": 1}, {a: {x: 2}}); - nomatch({"a.b.c": 1}, {a: {b: {x: 2}}}); - nomatch({"a.b.c": 1}, {a: {b: 1}}); - nomatch({"a.b.c": 1}, {a: {b: 0}}); - - // dotted keypaths: literal objects - match({"a.b": {c: 1}}, {a: {b: {c: 1}}}); - nomatch({"a.b": {c: 1}}, {a: {b: {c: 2}}}); - nomatch({"a.b": {c: 1}}, {a: {b: 2}}); - match({"a.b": {c: 1, d: 2}}, {a: {b: {c: 1, d: 2}}}); - nomatch({"a.b": {c: 1, d: 2}}, {a: {b: {c: 1, d: 1}}}); - nomatch({"a.b": {c: 1, d: 2}}, {a: {b: {d: 2}}}); - - // dotted keypaths: $ operators - match({"a.b": {$in: [1, 2, 3]}}, {a: {b: [2]}}); // tested against mongodb - match({"a.b": {$in: [{x: 1}, {x: 2}, {x: 3}]}}, {a: {b: [{x: 2}]}}); - match({"a.b": {$in: [1, 2, 3]}}, {a: {b: [4, 2]}}); - nomatch({"a.b": {$in: [1, 2, 3]}}, {a: {b: [4]}}); - - // $or - test.throws(function () { - match({$or: []}, {}); - }); - test.throws(function () { - match({$or: [5]}, {}); - }); - test.throws(function () { - match({$or: []}, {a: 1}); - }); - match({$or: [{a: 1}]}, {a: 1}); - nomatch({$or: [{b: 2}]}, {a: 1}); - match({$or: [{a: 1}, {b: 2}]}, {a: 1}); - nomatch({$or: [{c: 3}, {d: 4}]}, {a: 1}); - match({$or: [{a: 1}, {b: 2}]}, {a: [1, 2, 3]}); - nomatch({$or: [{a: 1}, {b: 2}]}, {c: [1, 2, 3]}); - nomatch({$or: [{a: 1}, {b: 2}]}, {a: [2, 3, 4]}); - match({$or: [{a: 1}, {a: 2}]}, {a: 1}); - match({$or: [{a: 1}, {a: 2}], b: 2}, {a: 1, b: 2}); - nomatch({$or: [{a: 2}, {a: 3}], b: 2}, {a: 1, b: 2}); - nomatch({$or: [{a: 1}, {a: 2}], b: 3}, {a: 1, b: 2}); - - // Combining $or with equality - match({x: 1, $or: [{a: 1}, {b: 1}]}, {x: 1, b: 1}); - match({$or: [{a: 1}, {b: 1}], x: 1}, {x: 1, b: 1}); - nomatch({x: 1, $or: [{a: 1}, {b: 1}]}, {b: 1}); - nomatch({x: 1, $or: [{a: 1}, {b: 1}]}, {x: 1}); - - // $or and $lt, $lte, $gt, $gte - match({$or: [{a: {$lte: 1}}, {a: 2}]}, {a: 1}); - nomatch({$or: [{a: {$lt: 1}}, {a: 2}]}, {a: 1}); - match({$or: [{a: {$gte: 1}}, {a: 2}]}, {a: 1}); - nomatch({$or: [{a: {$gt: 1}}, {a: 2}]}, {a: 1}); - match({$or: [{b: {$gt: 1}}, {b: {$lt: 3}}]}, {b: 2}); - nomatch({$or: [{b: {$lt: 1}}, {b: {$gt: 3}}]}, {b: 2}); - - // $or and $in - match({$or: [{a: {$in: [1, 2, 3]}}]}, {a: 1}); - nomatch({$or: [{a: {$in: [4, 5, 6]}}]}, {a: 1}); - match({$or: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {a: 1}); - match({$or: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {b: 2}); - nomatch({$or: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {c: 3}); - match({$or: [{a: {$in: [1, 2, 3]}}, {b: {$in: [1, 2, 3]}}]}, {b: 2}); - nomatch({$or: [{a: {$in: [1, 2, 3]}}, {b: {$in: [4, 5, 6]}}]}, {b: 2}); - - // $or and $nin - nomatch({$or: [{a: {$nin: [1, 2, 3]}}]}, {a: 1}); - match({$or: [{a: {$nin: [4, 5, 6]}}]}, {a: 1}); - nomatch({$or: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {a: 1}); - match({$or: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {b: 2}); - match({$or: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {c: 3}); - match({$or: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {b: 2}); - nomatch({$or: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {a: 1, b: 2}); - match({$or: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [4, 5, 6]}}]}, {b: 2}); - - // $or and dot-notation - match({$or: [{"a.b": 1}, {"a.b": 2}]}, {a: {b: 1}}); - match({$or: [{"a.b": 1}, {"a.c": 1}]}, {a: {b: 1}}); - nomatch({$or: [{"a.b": 2}, {"a.c": 1}]}, {a: {b: 1}}); - - // $or and nested objects - match({$or: [{a: {b: 1, c: 2}}, {a: {b: 2, c: 1}}]}, {a: {b: 1, c: 2}}); - nomatch({$or: [{a: {b: 1, c: 3}}, {a: {b: 2, c: 1}}]}, {a: {b: 1, c: 2}}); - - // $or and regexes - match({$or: [{a: /a/}]}, {a: "cat"}); - nomatch({$or: [{a: /o/}]}, {a: "cat"}); - match({$or: [{a: /a/}, {a: /o/}]}, {a: "cat"}); - nomatch({$or: [{a: /i/}, {a: /o/}]}, {a: "cat"}); - match({$or: [{a: /i/}, {b: /o/}]}, {a: "cat", b: "dog"}); - - // $or and $ne - match({$or: [{a: {$ne: 1}}]}, {}); - nomatch({$or: [{a: {$ne: 1}}]}, {a: 1}); - match({$or: [{a: {$ne: 1}}]}, {a: 2}); - match({$or: [{a: {$ne: 1}}]}, {b: 1}); - match({$or: [{a: {$ne: 1}}, {a: {$ne: 2}}]}, {a: 1}); - match({$or: [{a: {$ne: 1}}, {b: {$ne: 1}}]}, {a: 1}); - nomatch({$or: [{a: {$ne: 1}}, {b: {$ne: 2}}]}, {a: 1, b: 2}); - - // $or and $not - match({$or: [{a: {$not: {$mod: [10, 1]}}}]}, {}); - nomatch({$or: [{a: {$not: {$mod: [10, 1]}}}]}, {a: 1}); - match({$or: [{a: {$not: {$mod: [10, 1]}}}]}, {a: 2}); - match({$or: [{a: {$not: {$mod: [10, 1]}}}, {a: {$not: {$mod: [10, 2]}}}]}, {a: 1}); - nomatch({$or: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 1}); - match({$or: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 2}); - match({$or: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 3}); - // this is possibly an open-ended task, so we stop here ... - - // $nor - test.throws(function () { - match({$nor: []}, {}); - }); - test.throws(function () { - match({$nor: [5]}, {}); - }); - test.throws(function () { - match({$nor: []}, {a: 1}); - }); - nomatch({$nor: [{a: 1}]}, {a: 1}); - match({$nor: [{b: 2}]}, {a: 1}); - nomatch({$nor: [{a: 1}, {b: 2}]}, {a: 1}); - match({$nor: [{c: 3}, {d: 4}]}, {a: 1}); - nomatch({$nor: [{a: 1}, {b: 2}]}, {a: [1, 2, 3]}); - match({$nor: [{a: 1}, {b: 2}]}, {c: [1, 2, 3]}); - match({$nor: [{a: 1}, {b: 2}]}, {a: [2, 3, 4]}); - nomatch({$nor: [{a: 1}, {a: 2}]}, {a: 1}); - - // $nor and $lt, $lte, $gt, $gte - nomatch({$nor: [{a: {$lte: 1}}, {a: 2}]}, {a: 1}); - match({$nor: [{a: {$lt: 1}}, {a: 2}]}, {a: 1}); - nomatch({$nor: [{a: {$gte: 1}}, {a: 2}]}, {a: 1}); - match({$nor: [{a: {$gt: 1}}, {a: 2}]}, {a: 1}); - nomatch({$nor: [{b: {$gt: 1}}, {b: {$lt: 3}}]}, {b: 2}); - match({$nor: [{b: {$lt: 1}}, {b: {$gt: 3}}]}, {b: 2}); - - // $nor and $in - nomatch({$nor: [{a: {$in: [1, 2, 3]}}]}, {a: 1}); - match({$nor: [{a: {$in: [4, 5, 6]}}]}, {a: 1}); - nomatch({$nor: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {a: 1}); - nomatch({$nor: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {b: 2}); - match({$nor: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {c: 3}); - nomatch({$nor: [{a: {$in: [1, 2, 3]}}, {b: {$in: [1, 2, 3]}}]}, {b: 2}); - match({$nor: [{a: {$in: [1, 2, 3]}}, {b: {$in: [4, 5, 6]}}]}, {b: 2}); - - // $nor and $nin - match({$nor: [{a: {$nin: [1, 2, 3]}}]}, {a: 1}); - nomatch({$nor: [{a: {$nin: [4, 5, 6]}}]}, {a: 1}); - match({$nor: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {a: 1}); - nomatch({$nor: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {b: 2}); - nomatch({$nor: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {c: 3}); - nomatch({$nor: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {b: 2}); - match({$nor: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {a: 1, b: 2}); - nomatch({$nor: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [4, 5, 6]}}]}, {b: 2}); - - // $nor and dot-notation - nomatch({$nor: [{"a.b": 1}, {"a.b": 2}]}, {a: {b: 1}}); - nomatch({$nor: [{"a.b": 1}, {"a.c": 1}]}, {a: {b: 1}}); - match({$nor: [{"a.b": 2}, {"a.c": 1}]}, {a: {b: 1}}); - - // $nor and nested objects - nomatch({$nor: [{a: {b: 1, c: 2}}, {a: {b: 2, c: 1}}]}, {a: {b: 1, c: 2}}); - match({$nor: [{a: {b: 1, c: 3}}, {a: {b: 2, c: 1}}]}, {a: {b: 1, c: 2}}); - - // $nor and regexes - nomatch({$nor: [{a: /a/}]}, {a: "cat"}); - match({$nor: [{a: /o/}]}, {a: "cat"}); - nomatch({$nor: [{a: /a/}, {a: /o/}]}, {a: "cat"}); - match({$nor: [{a: /i/}, {a: /o/}]}, {a: "cat"}); - nomatch({$nor: [{a: /i/}, {b: /o/}]}, {a: "cat", b: "dog"}); - - // $nor and $ne - nomatch({$nor: [{a: {$ne: 1}}]}, {}); - match({$nor: [{a: {$ne: 1}}]}, {a: 1}); - nomatch({$nor: [{a: {$ne: 1}}]}, {a: 2}); - nomatch({$nor: [{a: {$ne: 1}}]}, {b: 1}); - nomatch({$nor: [{a: {$ne: 1}}, {a: {$ne: 2}}]}, {a: 1}); - nomatch({$nor: [{a: {$ne: 1}}, {b: {$ne: 1}}]}, {a: 1}); - match({$nor: [{a: {$ne: 1}}, {b: {$ne: 2}}]}, {a: 1, b: 2}); - - // $nor and $not - nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}]}, {}); - match({$nor: [{a: {$not: {$mod: [10, 1]}}}]}, {a: 1}); - nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}]}, {a: 2}); - nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}, {a: {$not: {$mod: [10, 2]}}}]}, {a: 1}); - match({$nor: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 1}); - nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 2}); - nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 3}); - - // $and - - test.throws(function () { - match({$and: []}, {}); - }); - test.throws(function () { - match({$and: [5]}, {}); - }); - test.throws(function () { - match({$and: []}, {a: 1}); - }); - match({$and: [{a: 1}]}, {a: 1}); - nomatch({$and: [{a: 1}, {a: 2}]}, {a: 1}); - nomatch({$and: [{a: 1}, {b: 1}]}, {a: 1}); - match({$and: [{a: 1}, {b: 2}]}, {a: 1, b: 2}); - nomatch({$and: [{a: 1}, {b: 1}]}, {a: 1, b: 2}); - match({$and: [{a: 1}, {b: 2}], c: 3}, {a: 1, b: 2, c: 3}); - nomatch({$and: [{a: 1}, {b: 2}], c: 4}, {a: 1, b: 2, c: 3}); - - // $and and regexes - match({$and: [{a: /a/}]}, {a: "cat"}); - match({$and: [{a: /a/i}]}, {a: "CAT"}); - nomatch({$and: [{a: /o/}]}, {a: "cat"}); - nomatch({$and: [{a: /a/}, {a: /o/}]}, {a: "cat"}); - match({$and: [{a: /a/}, {b: /o/}]}, {a: "cat", b: "dog"}); - nomatch({$and: [{a: /a/}, {b: /a/}]}, {a: "cat", b: "dog"}); - - // $and, dot-notation, and nested objects - match({$and: [{"a.b": 1}]}, {a: {b: 1}}); - match({$and: [{a: {b: 1}}]}, {a: {b: 1}}); - nomatch({$and: [{"a.b": 2}]}, {a: {b: 1}}); - nomatch({$and: [{"a.c": 1}]}, {a: {b: 1}}); - nomatch({$and: [{"a.b": 1}, {"a.b": 2}]}, {a: {b: 1}}); - nomatch({$and: [{"a.b": 1}, {a: {b: 2}}]}, {a: {b: 1}}); - match({$and: [{"a.b": 1}, {"c.d": 2}]}, {a: {b: 1}, c: {d: 2}}); - nomatch({$and: [{"a.b": 1}, {"c.d": 1}]}, {a: {b: 1}, c: {d: 2}}); - match({$and: [{"a.b": 1}, {c: {d: 2}}]}, {a: {b: 1}, c: {d: 2}}); - nomatch({$and: [{"a.b": 1}, {c: {d: 1}}]}, {a: {b: 1}, c: {d: 2}}); - nomatch({$and: [{"a.b": 2}, {c: {d: 2}}]}, {a: {b: 1}, c: {d: 2}}); - match({$and: [{a: {b: 1}}, {c: {d: 2}}]}, {a: {b: 1}, c: {d: 2}}); - nomatch({$and: [{a: {b: 2}}, {c: {d: 2}}]}, {a: {b: 1}, c: {d: 2}}); - - // $and and $in - nomatch({$and: [{a: {$in: []}}]}, {}); - match({$and: [{a: {$in: [1, 2, 3]}}]}, {a: 1}); - nomatch({$and: [{a: {$in: [4, 5, 6]}}]}, {a: 1}); - nomatch({$and: [{a: {$in: [1, 2, 3]}}, {a: {$in: [4, 5, 6]}}]}, {a: 1}); - nomatch({$and: [{a: {$in: [1, 2, 3]}}, {b: {$in: [1, 2, 3]}}]}, {a: 1, b: 4}); - match({$and: [{a: {$in: [1, 2, 3]}}, {b: {$in: [4, 5, 6]}}]}, {a: 1, b: 4}); - - - // $and and $nin - match({$and: [{a: {$nin: []}}]}, {}); - nomatch({$and: [{a: {$nin: [1, 2, 3]}}]}, {a: 1}); - match({$and: [{a: {$nin: [4, 5, 6]}}]}, {a: 1}); - nomatch({$and: [{a: {$nin: [1, 2, 3]}}, {a: {$nin: [4, 5, 6]}}]}, {a: 1}); - nomatch({$and: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {a: 1, b: 4}); - nomatch({$and: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [4, 5, 6]}}]}, {a: 1, b: 4}); - - // $and and $lt, $lte, $gt, $gte - match({$and: [{a: {$lt: 2}}]}, {a: 1}); - nomatch({$and: [{a: {$lt: 1}}]}, {a: 1}); - match({$and: [{a: {$lte: 1}}]}, {a: 1}); - match({$and: [{a: {$gt: 0}}]}, {a: 1}); - nomatch({$and: [{a: {$gt: 1}}]}, {a: 1}); - match({$and: [{a: {$gte: 1}}]}, {a: 1}); - match({$and: [{a: {$gt: 0}}, {a: {$lt: 2}}]}, {a: 1}); - nomatch({$and: [{a: {$gt: 1}}, {a: {$lt: 2}}]}, {a: 1}); - nomatch({$and: [{a: {$gt: 0}}, {a: {$lt: 1}}]}, {a: 1}); - match({$and: [{a: {$gte: 1}}, {a: {$lte: 1}}]}, {a: 1}); - nomatch({$and: [{a: {$gte: 2}}, {a: {$lte: 0}}]}, {a: 1}); - - // $and and $ne - match({$and: [{a: {$ne: 1}}]}, {}); - nomatch({$and: [{a: {$ne: 1}}]}, {a: 1}); - match({$and: [{a: {$ne: 1}}]}, {a: 2}); - nomatch({$and: [{a: {$ne: 1}}, {a: {$ne: 2}}]}, {a: 2}); - match({$and: [{a: {$ne: 1}}, {a: {$ne: 3}}]}, {a: 2}); - - // $and and $not - match({$and: [{a: {$not: {$gt: 2}}}]}, {a: 1}); - nomatch({$and: [{a: {$not: {$lt: 2}}}]}, {a: 1}); - match({$and: [{a: {$not: {$lt: 0}}}, {a: {$not: {$gt: 2}}}]}, {a: 1}); - nomatch({$and: [{a: {$not: {$lt: 2}}}, {a: {$not: {$gt: 0}}}]}, {a: 1}); - - // $where - match({$where: "this.a === 1"}, {a: 1}); - match({$where: "obj.a === 1"}, {a: 1}); - nomatch({$where: "this.a !== 1"}, {a: 1}); - nomatch({$where: "obj.a !== 1"}, {a: 1}); - nomatch({$where: "this.a === 1", a: 2}, {a: 1}); - match({$where: "this.a === 1", b: 2}, {a: 1, b: 2}); - match({$where: "this.a === 1 && this.b === 2"}, {a: 1, b: 2}); - match({$where: "this.a instanceof Array"}, {a: []}); - nomatch({$where: "this.a instanceof Array"}, {a: 1}); - - // reaching into array - match({"dogs.0.name": "Fido"}, {dogs: [{name: "Fido"}, {name: "Rex"}]}); - match({"dogs.1.name": "Rex"}, {dogs: [{name: "Fido"}, {name: "Rex"}]}); - nomatch({"dogs.1.name": "Fido"}, {dogs: [{name: "Fido"}, {name: "Rex"}]}); - match({"room.1b": "bla"}, {room: {"1b": "bla"}}); - - match({"dogs.name": "Fido"}, {dogs: [{name: "Fido"}, {name: "Rex"}]}); - match({"dogs.name": "Rex"}, {dogs: [{name: "Fido"}, {name: "Rex"}]}); - match({"animals.dogs.name": "Fido"}, - {animals: [{dogs: [{name: "Rover"}]}, - {}, - {dogs: [{name: "Fido"}, {name: "Rex"}]}]}); - match({"animals.dogs.name": "Fido"}, - {animals: [{dogs: {name: "Rex"}}, - {dogs: {name: "Fido"}}]}); - match({"animals.dogs.name": "Fido"}, - {animals: [{dogs: [{name: "Rover"}]}, - {}, - {dogs: [{name: ["Fido"]}, {name: "Rex"}]}]}); - nomatch({"dogs.name": "Fido"}, {dogs: []}); - - // $elemMatch - match({dogs: {$elemMatch: {name: /e/}}}, - {dogs: [{name: "Fido"}, {name: "Rex"}]}); - nomatch({dogs: {$elemMatch: {name: /a/}}}, - {dogs: [{name: "Fido"}, {name: "Rex"}]}); - match({dogs: {$elemMatch: {age: {$gt: 4}}}}, - {dogs: [{name: "Fido", age: 5}, {name: "Rex", age: 3}]}); - match({dogs: {$elemMatch: {name: "Fido", age: {$gt: 4}}}}, - {dogs: [{name: "Fido", age: 5}, {name: "Rex", age: 3}]}); - nomatch({dogs: {$elemMatch: {name: "Fido", age: {$gt: 5}}}}, - {dogs: [{name: "Fido", age: 5}, {name: "Rex", age: 3}]}); - match({dogs: {$elemMatch: {name: /i/, age: {$gt: 4}}}}, - {dogs: [{name: "Fido", age: 5}, {name: "Rex", age: 3}]}); - nomatch({dogs: {$elemMatch: {name: /e/, age: 5}}}, - {dogs: [{name: "Fido", age: 5}, {name: "Rex", age: 3}]}); - match({x: {$elemMatch: {y: 9}}}, {x: [{y: 9}]}); - nomatch({x: {$elemMatch: {y: 9}}}, {x: [[{y: 9}]]}); - match({x: {$elemMatch: {$gt: 5, $lt: 9}}}, {x: [8]}); - nomatch({x: {$elemMatch: {$gt: 5, $lt: 9}}}, {x: [[8]]}); - match({'a.x': {$elemMatch: {y: 9}}}, - {a: [{x: []}, {x: [{y: 9}]}]}); - nomatch({a: {$elemMatch: {x: 5}}}, {a: {x: 5}}); - match({a: {$elemMatch: {0: {$gt: 5, $lt: 9}}}}, {a: [[6]]}); - match({a: {$elemMatch: {'0.b': {$gt: 5, $lt: 9}}}}, {a: [[{b:6}]]}); - match({a: {$elemMatch: {x: 1, $or: [{a: 1}, {b: 1}]}}}, - {a: [{x: 1, b: 1}]}); - match({a: {$elemMatch: {$or: [{a: 1}, {b: 1}], x: 1}}}, - {a: [{x: 1, b: 1}]}); - match({a: {$elemMatch: {$or: [{a: 1}, {b: 1}]}}}, - {a: [{x: 1, b: 1}]}); - match({a: {$elemMatch: {$or: [{a: 1}, {b: 1}]}}}, - {a: [{x: 1, b: 1}]}); - match({a: {$elemMatch: {$and: [{b: 1}, {x: 1}]}}}, - {a: [{x: 1, b: 1}]}); - nomatch({a: {$elemMatch: {x: 1, $or: [{a: 1}, {b: 1}]}}}, - {a: [{b: 1}]}); - nomatch({a: {$elemMatch: {x: 1, $or: [{a: 1}, {b: 1}]}}}, - {a: [{x: 1}]}); - nomatch({a: {$elemMatch: {x: 1, $or: [{a: 1}, {b: 1}]}}}, - {a: [{x: 1}, {b: 1}]}); - - test.throws(function () { - match({a: {$elemMatch: {$gte: 1, $or: [{a: 1}, {b: 1}]}}}, - {a: [{x: 1, b: 1}]}); - }); - - test.throws(function () { - match({x: {$elemMatch: {$and: [{$gt: 5, $lt: 9}]}}}, {x: [8]}); - }); - - // $comment - match({a: 5, $comment: "asdf"}, {a: 5}); - nomatch({a: 6, $comment: "asdf"}, {a: 5}); - - // XXX still needs tests: - // - non-scalar arguments to $gt, $lt, etc -}); - -Tinytest.add("minimongo - projection_compiler", function (test) { - var testProjection = function (projection, tests) { - var projection_f = LocalCollection._compileProjection(projection); - var equalNonStrict = function (a, b, desc) { - test.isTrue(_.isEqual(a, b), desc); - }; - - _.each(tests, function (testCase) { - equalNonStrict(projection_f(testCase[0]), testCase[1], testCase[2]); - }); - }; - - var testCompileProjectionThrows = function (projection, expectedError) { - test.throws(function () { - LocalCollection._compileProjection(projection); - }, expectedError); - }; - - testProjection({ 'foo': 1, 'bar': 1 }, [ - [{ foo: 42, bar: "something", baz: "else" }, - { foo: 42, bar: "something" }, - "simplest - whitelist"], - - [{ foo: { nested: 17 }, baz: {} }, - { foo: { nested: 17 } }, - "nested whitelisted field"], - - [{ _id: "uid", bazbaz: 42 }, - { _id: "uid" }, - "simplest whitelist - preserve _id"] - ]); - - testProjection({ 'foo': 0, 'bar': 0 }, [ - [{ foo: 42, bar: "something", baz: "else" }, - { baz: "else" }, - "simplest - blacklist"], - - [{ foo: { nested: 17 }, baz: { foo: "something" } }, - { baz: { foo: "something" } }, - "nested blacklisted field"], - - [{ _id: "uid", bazbaz: 42 }, - { _id: "uid", bazbaz: 42 }, - "simplest blacklist - preserve _id"] - ]); - - testProjection({ _id: 0, foo: 1 }, [ - [{ foo: 42, bar: 33, _id: "uid" }, - { foo: 42 }, - "whitelist - _id blacklisted"] - ]); - - testProjection({ _id: 0, foo: 0 }, [ - [{ foo: 42, bar: 33, _id: "uid" }, - { bar: 33 }, - "blacklist - _id blacklisted"] - ]); - - testProjection({ 'foo.bar.baz': 1 }, [ - [{ foo: { meh: "fur", bar: { baz: 42 }, tr: 1 }, bar: 33, baz: 'trolololo' }, - { foo: { bar: { baz: 42 } } }, - "whitelist nested"], - - // Behavior of this test is looked up in actual mongo - [{ foo: { meh: "fur", bar: "nope", tr: 1 }, bar: 33, baz: 'trolololo' }, - { foo: {} }, - "whitelist nested - path not found in doc, different type"], - - // Behavior of this test is looked up in actual mongo - [{ foo: { meh: "fur", bar: [], tr: 1 }, bar: 33, baz: 'trolololo' }, - { foo: { bar: [] } }, - "whitelist nested - path not found in doc"] - ]); - - testProjection({ 'hope.humanity': 0, 'hope.people': 0 }, [ - [{ hope: { humanity: "lost", people: 'broken', candies: 'long live!' } }, - { hope: { candies: 'long live!' } }, - "blacklist nested"], - - [{ hope: "new" }, - { hope: "new" }, - "blacklist nested - path not found in doc"] - ]); - - testProjection({ _id: 1 }, [ - [{ _id: 42, x: 1, y: { z: "2" } }, - { _id: 42 }, - "_id whitelisted"], - [{ _id: 33 }, - { _id: 33 }, - "_id whitelisted, _id only"], - [{ x: 1 }, - {}, - "_id whitelisted, no _id"] - ]); - - testProjection({ _id: 0 }, [ - [{ _id: 42, x: 1, y: { z: "2" } }, - { x: 1, y: { z: "2" } }, - "_id blacklisted"], - [{ _id: 33 }, - {}, - "_id blacklisted, _id only"], - [{ x: 1 }, - { x: 1 }, - "_id blacklisted, no _id"] - ]); - - testProjection({}, [ - [{ a: 1, b: 2, c: "3" }, - { a: 1, b: 2, c: "3" }, - "empty projection"] - ]); - - testCompileProjectionThrows( - { 'inc': 1, 'excl': 0 }, - "You cannot currently mix including and excluding fields"); - testCompileProjectionThrows( - { _id: 1, a: 0 }, - "You cannot currently mix including and excluding fields"); - - testCompileProjectionThrows( - { 'a': 1, 'a.b': 1 }, - "using both of them may trigger unexpected behavior"); - testCompileProjectionThrows( - { 'a.b.c': 1, 'a.b': 1, 'a': 1 }, - "using both of them may trigger unexpected behavior"); - - testCompileProjectionThrows("some string", "fields option must be an object"); -}); - -Tinytest.add("minimongo - fetch with fields", function (test) { - var c = new LocalCollection(); - _.times(30, function (i) { - c.insert({ - something: Random.id(), - anything: { - foo: "bar", - cool: "hot" - }, - nothing: i, - i: i - }); - }); - - // Test just a regular fetch with some projection - var fetchResults = c.find({}, { fields: { - 'something': 1, - 'anything.foo': 1 - } }).fetch(); - - test.isTrue(_.all(fetchResults, function (x) { - return x && - x.something && - x.anything && - x.anything.foo && - x.anything.foo === "bar" && - !_.has(x, 'nothing') && - !_.has(x.anything, 'cool'); - })); - - // Test with a selector, even field used in the selector is excluded in the - // projection - fetchResults = c.find({ - nothing: { $gte: 5 } - }, { - fields: { nothing: 0 } - }).fetch(); - - test.isTrue(_.all(fetchResults, function (x) { - return x && - x.something && - x.anything && - x.anything.foo === "bar" && - x.anything.cool === "hot" && - !_.has(x, 'nothing') && - x.i && - x.i >= 5; - })); - - test.isTrue(fetchResults.length === 25); - - // Test that we can sort, based on field excluded from the projection, use - // skip and limit as well! - // following find will get indexes [10..20) sorted by nothing - fetchResults = c.find({}, { - sort: { - nothing: 1 - }, - limit: 10, - skip: 10, - fields: { - i: 1, - something: 1 - } - }).fetch(); - - test.isTrue(_.all(fetchResults, function (x) { - return x && - x.something && - x.i >= 10 && x.i < 20; - })); - - _.each(fetchResults, function (x, i, arr) { - if (!i) return; - test.isTrue(x.i === arr[i-1].i + 1); - }); - - // Temporary unsupported operators - // queries are taken from MongoDB docs examples - test.throws(function () { - c.find({}, { fields: { 'grades.$': 1 } }); - }); - test.throws(function () { - c.find({}, { fields: { grades: { $elemMatch: { mean: 70 } } } }); - }); - test.throws(function () { - c.find({}, { fields: { grades: { $slice: [20, 10] } } }); - }); -}); - -Tinytest.add("minimongo - fetch with projection, subarrays", function (test) { - // Apparently projection of type 'foo.bar.x' for - // { foo: [ { bar: { x: 42 } }, { bar: { x: 3 } } ] } - // should return exactly this object. More precisely, arrays are considered as - // sets and are queried separately and then merged back to result set - var c = new LocalCollection(); - - // Insert a test object with two set fields - c.insert({ - setA: [{ - fieldA: 42, - fieldB: 33 - }, { - fieldA: "the good", - fieldB: "the bad", - fieldC: "the ugly" - }], - setB: [{ - anotherA: { }, - anotherB: "meh" - }, { - anotherA: 1234, - anotherB: 431 - }] - }); - - var equalNonStrict = function (a, b, desc) { - test.isTrue(_.isEqual(a, b), desc); - }; - - var testForProjection = function (projection, expected) { - var fetched = c.find({}, { fields: projection }).fetch()[0]; - equalNonStrict(fetched, expected, "failed sub-set projection: " + - JSON.stringify(projection)); - }; - - testForProjection({ 'setA.fieldA': 1, 'setB.anotherB': 1, _id: 0 }, - { - setA: [{ fieldA: 42 }, { fieldA: "the good" }], - setB: [{ anotherB: "meh" }, { anotherB: 431 }] - }); - - testForProjection({ 'setA.fieldA': 0, 'setB.anotherA': 0, _id: 0 }, - { - setA: [{fieldB:33}, {fieldB:"the bad",fieldC:"the ugly"}], - setB: [{ anotherB: "meh" }, { anotherB: 431 }] - }); - - c.remove({}); - c.insert({a:[[{b:1,c:2},{b:2,c:4}],{b:3,c:5},[{b:4, c:9}]]}); - - testForProjection({ 'a.b': 1, _id: 0 }, - {a: [ [ { b: 1 }, { b: 2 } ], { b: 3 }, [ { b: 4 } ] ] }); - testForProjection({ 'a.b': 0, _id: 0 }, - {a: [ [ { c: 2 }, { c: 4 } ], { c: 5 }, [ { c: 9 } ] ] }); -}); - -Tinytest.add("minimongo - fetch with projection, deep copy", function (test) { - // Compiled fields projection defines the contract: returned document doesn't - // retain anything from the passed argument. - var doc = { - a: { x: 42 }, - b: { - y: { z: 33 } - }, - c: "asdf" - }; - - var fields = { - 'a': 1, - 'b.y': 1 - }; - - var projectionFn = LocalCollection._compileProjection(fields); - var filteredDoc = projectionFn(doc); - doc.a.x++; - doc.b.y.z--; - test.equal(filteredDoc.a.x, 42, "projection returning deep copy - including"); - test.equal(filteredDoc.b.y.z, 33, "projection returning deep copy - including"); - - fields = { c: 0 }; - projectionFn = LocalCollection._compileProjection(fields); - filteredDoc = projectionFn(doc); - - doc.a.x = 5; - test.equal(filteredDoc.a.x, 43, "projection returning deep copy - excluding"); -}); - -Tinytest.add("minimongo - observe ordered with projection", function (test) { - // These tests are copy-paste from "minimongo -observe ordered", - // slightly modified to test projection - var operations = []; - var cbs = log_callbacks(operations); - var handle; - - var c = new LocalCollection(); - handle = c.find({}, {sort: {a: 1}, fields: { a: 1 }}).observe(cbs); - test.isTrue(handle.collection === c); - - c.insert({_id: 'foo', a:1, b:2}); - test.equal(operations.shift(), ['added', {a:1}, 0, null]); - c.update({a:1}, {$set: {a: 2, b: 1}}); - test.equal(operations.shift(), ['changed', {a:2}, 0, {a:1}]); - c.insert({_id: 'bar', a:10, c: 33}); - test.equal(operations.shift(), ['added', {a:10}, 1, null]); - c.update({}, {$inc: {a: 1}}, {multi: true}); - c.update({}, {$inc: {c: 1}}, {multi: true}); - test.equal(operations.shift(), ['changed', {a:3}, 0, {a:2}]); - test.equal(operations.shift(), ['changed', {a:11}, 1, {a:10}]); - c.update({a:11}, {a:1, b:44}); - test.equal(operations.shift(), ['changed', {a:1}, 1, {a:11}]); - test.equal(operations.shift(), ['moved', {a:1}, 1, 0, 'foo']); - c.remove({a:2}); - test.equal(operations.shift(), undefined); - c.remove({a:3}); - test.equal(operations.shift(), ['removed', 'foo', 1, {a:3}]); - - // test stop - handle.stop(); - var idA2 = Random.id(); - c.insert({_id: idA2, a:2}); - test.equal(operations.shift(), undefined); - - var cursor = c.find({}, {fields: {a: 1, _id: 0}}); - test.throws(function () { - cursor.observeChanges({added: function () {}}); - }); - test.throws(function () { - cursor.observe({added: function () {}}); - }); - - // test initial inserts (and backwards sort) - handle = c.find({}, {sort: {a: -1}, fields: { a: 1 } }).observe(cbs); - test.equal(operations.shift(), ['added', {a:2}, 0, null]); - test.equal(operations.shift(), ['added', {a:1}, 1, null]); - handle.stop(); - - // test _suppress_initial - handle = c.find({}, {sort: {a: -1}, fields: { a: 1 }}).observe(_.extend(cbs, {_suppress_initial: true})); - test.equal(operations.shift(), undefined); - c.insert({a:100, b: { foo: "bar" }}); - test.equal(operations.shift(), ['added', {a:100}, 0, idA2]); - handle.stop(); - - // test skip and limit. - c.remove({}); - handle = c.find({}, {sort: {a: 1}, skip: 1, limit: 2, fields: { 'blacklisted': 0 }}).observe(cbs); - test.equal(operations.shift(), undefined); - c.insert({a:1, blacklisted:1324}); - test.equal(operations.shift(), undefined); - c.insert({_id: 'foo', a:2, blacklisted:["something"]}); - test.equal(operations.shift(), ['added', {a:2}, 0, null]); - c.insert({a:3, blacklisted: { 2: 3 }}); - test.equal(operations.shift(), ['added', {a:3}, 1, null]); - c.insert({a:4, blacklisted: 6}); - test.equal(operations.shift(), undefined); - c.update({a:1}, {a:0, blacklisted:4444}); - test.equal(operations.shift(), undefined); - c.update({a:0}, {a:5, blacklisted:11111}); - test.equal(operations.shift(), ['removed', 'foo', 0, {a:2}]); - test.equal(operations.shift(), ['added', {a:4}, 1, null]); - c.update({a:3}, {a:3.5, blacklisted:333.4444}); - test.equal(operations.shift(), ['changed', {a:3.5}, 0, {a:3}]); - handle.stop(); - - // test _no_indices - - c.remove({}); - handle = c.find({}, {sort: {a: 1}, fields: { a: 1 }}).observe(_.extend(cbs, {_no_indices: true})); - c.insert({_id: 'foo', a:1, zoo: "crazy"}); - test.equal(operations.shift(), ['added', {a:1}, -1, null]); - c.update({a:1}, {$set: {a: 2, foobar: "player"}}); - test.equal(operations.shift(), ['changed', {a:2}, -1, {a:1}]); - c.insert({a:10, b:123.45}); - test.equal(operations.shift(), ['added', {a:10}, -1, null]); - c.update({}, {$inc: {a: 1, b:2}}, {multi: true}); - test.equal(operations.shift(), ['changed', {a:3}, -1, {a:2}]); - test.equal(operations.shift(), ['changed', {a:11}, -1, {a:10}]); - c.update({a:11, b:125.45}, {a:1, b:444}); - test.equal(operations.shift(), ['changed', {a:1}, -1, {a:11}]); - test.equal(operations.shift(), ['moved', {a:1}, -1, -1, 'foo']); - c.remove({a:2}); - test.equal(operations.shift(), undefined); - c.remove({a:3}); - test.equal(operations.shift(), ['removed', 'foo', -1, {a:3}]); - handle.stop(); -}); - - -Tinytest.add("minimongo - ordering", function (test) { - var shortBinary = EJSON.newBinary(1); - shortBinary[0] = 128; - var longBinary1 = EJSON.newBinary(2); - longBinary1[1] = 42; - var longBinary2 = EJSON.newBinary(2); - longBinary2[1] = 50; - - var date1 = new Date; - var date2 = new Date(date1.getTime() + 1000); - - // value ordering - assert_ordering(test, LocalCollection._f._cmp, [ - null, - 1, 2.2, 3, - "03", "1", "11", "2", "a", "aaa", - {}, {a: 2}, {a: 3}, {a: 3, b: 4}, {b: 4}, {b: 4, a: 3}, - {b: {}}, {b: [1, 2, 3]}, {b: [1, 2, 4]}, - [], [1, 2], [1, 2, 3], [1, 2, 4], [1, 2, "4"], [1, 2, [4]], - shortBinary, longBinary1, longBinary2, - new MongoID.ObjectID("1234567890abcd1234567890"), - new MongoID.ObjectID("abcd1234567890abcd123456"), - false, true, - date1, date2 - ]); - - // document ordering under a sort specification - var verify = function (sorts, docs) { - _.each(_.isArray(sorts) ? sorts : [sorts], function (sort) { - var sorter = new Minimongo.Sorter(sort); - assert_ordering(test, sorter.getComparator(), docs); - }); - }; - - // note: [] doesn't sort with "arrays", it sorts as "undefined". the position - // of arrays in _typeorder only matters for things like $lt. (This behavior - // verified with MongoDB 2.2.1.) We don't define the relative order of {a: []} - // and {c: 1} is undefined (MongoDB does seem to care but it's not clear how - // or why). - verify([{"a" : 1}, ["a"], [["a", "asc"]]], - [{a: []}, {a: 1}, {a: {}}, {a: true}]); - verify([{"a" : 1}, ["a"], [["a", "asc"]]], - [{c: 1}, {a: 1}, {a: {}}, {a: true}]); - verify([{"a" : -1}, [["a", "desc"]]], - [{a: true}, {a: {}}, {a: 1}, {c: 1}]); - verify([{"a" : -1}, [["a", "desc"]]], - [{a: true}, {a: {}}, {a: 1}, {a: []}]); - - verify([{"a" : 1, "b": -1}, ["a", ["b", "desc"]], - [["a", "asc"], ["b", "desc"]]], - [{c: 1}, {a: 1, b: 3}, {a: 1, b: 2}, {a: 2, b: 0}]); - - verify([{"a" : 1, "b": 1}, ["a", "b"], - [["a", "asc"], ["b", "asc"]]], - [{c: 1}, {a: 1, b: 2}, {a: 1, b: 3}, {a: 2, b: 0}]); - - test.throws(function () { - new Minimongo.Sorter("a"); - }); - - test.throws(function () { - new Minimongo.Sorter(123); - }); - - // We don't support $natural:1 (since we don't actually have Mongo's on-disk - // ordering available!) - test.throws(function () { - new Minimongo.Sorter({$natural: 1}); - }); - - // No sort spec implies everything equal. - test.equal(new Minimongo.Sorter({}).getComparator()({a:1}, {a:2}), 0); - - // All sorts of array edge cases! - // Increasing sort sorts by the smallest element it finds; 1 < 2. - verify({a: 1}, [ - {a: [1, 10, 20]}, - {a: [5, 2, 99]} - ]); - // Decreasing sorts by largest it finds; 99 > 20. - verify({a: -1}, [ - {a: [5, 2, 99]}, - {a: [1, 10, 20]} - ]); - // Can also sort by specific array indices. - verify({'a.1': 1}, [ - {a: [5, 2, 99]}, - {a: [1, 10, 20]} - ]); - // We do NOT expand sub-arrays, so the minimum in the second doc is 5, not - // -20. (Numbers always sort before arrays.) - verify({a: 1}, [ - {a: [1, [10, 15], 20]}, - {a: [5, [-5, -20], 18]} - ]); - // The maximum in each of these is the array, since arrays are "greater" than - // numbers. And [10, 15] is greater than [-5, -20]. - verify({a: -1}, [ - {a: [1, [10, 15], 20]}, - {a: [5, [-5, -20], 18]} - ]); - // 'a.0' here ONLY means "first element of a", not "first element of something - // found in a", so it CANNOT find the 10 or -5. - verify({'a.0': 1}, [ - {a: [1, [10, 15], 20]}, - {a: [5, [-5, -20], 18]} - ]); - verify({'a.0': -1}, [ - {a: [5, [-5, -20], 18]}, - {a: [1, [10, 15], 20]} - ]); - // Similarly, this is just comparing [-5,-20] to [10, 15]. - verify({'a.1': 1}, [ - {a: [5, [-5, -20], 18]}, - {a: [1, [10, 15], 20]} - ]); - verify({'a.1': -1}, [ - {a: [1, [10, 15], 20]}, - {a: [5, [-5, -20], 18]} - ]); - // Here we are just comparing [10,15] directly to [19,3] (and NOT also - // iterating over the numbers; this is implemented by setting dontIterate in - // makeLookupFunction). So [10,15]<[19,3] even though 3 is the smallest - // number you can find there. - verify({'a.1': 1}, [ - {a: [1, [10, 15], 20]}, - {a: [5, [19, 3], 18]} - ]); - verify({'a.1': -1}, [ - {a: [5, [19, 3], 18]}, - {a: [1, [10, 15], 20]} - ]); - // Minimal elements are 1 and 5. - verify({a: 1}, [ - {a: [1, [10, 15], 20]}, - {a: [5, [19, 3], 18]} - ]); - // Maximal elements are [19,3] and [10,15] (because arrays sort higher than - // numbers), even though there's a 20 floating around. - verify({a: -1}, [ - {a: [5, [19, 3], 18]}, - {a: [1, [10, 15], 20]} - ]); - // Maximal elements are [10,15] and [3,19]. [10,15] is bigger even though 19 - // is the biggest number in them, because array comparison is lexicographic. - verify({a: -1}, [ - {a: [1, [10, 15], 20]}, - {a: [5, [3, 19], 18]} - ]); - - // (0,4) < (0,5), so they go in this order. It's not correct to consider - // (0,3) as a sort key for the second document because they come from - // different a-branches. - verify({'a.x': 1, 'a.y': 1}, [ - {a: [{x: 0, y: 4}]}, - {a: [{x: 0, y: 5}, {x: 1, y: 3}]} - ]); - - verify({'a.0.s': 1}, [ - {a: [ {s: 1} ]}, - {a: [ {s: 2} ]} - ]); -}); - -Tinytest.add("minimongo - sort", function (test) { - var c = new LocalCollection(); - for (var i = 0; i < 50; i++) - for (var j = 0; j < 2; j++) - c.insert({a: i, b: j, _id: i + "_" + j}); - - test.equal( - c.find({a: {$gt: 10}}, {sort: {b: -1, a: 1}, limit: 5}).fetch(), [ - {a: 11, b: 1, _id: "11_1"}, - {a: 12, b: 1, _id: "12_1"}, - {a: 13, b: 1, _id: "13_1"}, - {a: 14, b: 1, _id: "14_1"}, - {a: 15, b: 1, _id: "15_1"}]); - - test.equal( - c.find({a: {$gt: 10}}, {sort: {b: -1, a: 1}, skip: 3, limit: 5}).fetch(), [ - {a: 14, b: 1, _id: "14_1"}, - {a: 15, b: 1, _id: "15_1"}, - {a: 16, b: 1, _id: "16_1"}, - {a: 17, b: 1, _id: "17_1"}, - {a: 18, b: 1, _id: "18_1"}]); - - test.equal( - c.find({a: {$gte: 20}}, {sort: {a: 1, b: -1}, skip: 50, limit: 5}).fetch(), [ - {a: 45, b: 1, _id: "45_1"}, - {a: 45, b: 0, _id: "45_0"}, - {a: 46, b: 1, _id: "46_1"}, - {a: 46, b: 0, _id: "46_0"}, - {a: 47, b: 1, _id: "47_1"}]); -}); - -Tinytest.add("minimongo - subkey sort", function (test) { - var c = new LocalCollection(); - - // normal case - c.insert({a: {b: 2}}); - c.insert({a: {b: 1}}); - c.insert({a: {b: 3}}); - test.equal( - _.pluck(c.find({}, {sort: {'a.b': -1}}).fetch(), 'a'), - [{b: 3}, {b: 2}, {b: 1}]); - - // isn't an object - c.insert({a: 1}); - test.equal( - _.pluck(c.find({}, {sort: {'a.b': 1}}).fetch(), 'a'), - [1, {b: 1}, {b: 2}, {b: 3}]); - - // complex object - c.insert({a: {b: {c: 1}}}); - test.equal( - _.pluck(c.find({}, {sort: {'a.b': -1}}).fetch(), 'a'), - [{b: {c: 1}}, {b: 3}, {b: 2}, {b: 1}, 1]); - - // no such top level prop - c.insert({c: 1}); - test.equal( - _.pluck(c.find({}, {sort: {'a.b': -1}}).fetch(), 'a'), - [{b: {c: 1}}, {b: 3}, {b: 2}, {b: 1}, 1, undefined]); - - // no such mid level prop. just test that it doesn't throw. - test.equal(c.find({}, {sort: {'a.nope.c': -1}}).count(), 6); -}); - -Tinytest.add("minimongo - array sort", function (test) { - var c = new LocalCollection(); - - // "up" and "down" are the indices that the docs should have when sorted - // ascending and descending by "a.x" respectively. They are not reverses of - // each other: when sorting ascending, you use the minimum value you can find - // in the document, and when sorting descending, you use the maximum value you - // can find. So [1, 4] shows up in the 1 slot when sorting ascending and the 4 - // slot when sorting descending. - // - // Similarly, "selected" is the index that the doc should have in the query - // that sorts ascending on "a.x" and selects {'a.x': {$gt: 1}}. In this case, - // the 1 in [1, 4] may not be used as a sort key. - c.insert({up: 1, down: 1, selected: 2, a: {x: [1, 4]}}); - c.insert({up: 2, down: 2, selected: 0, a: [{x: [2]}, {x: 3}]}); - c.insert({up: 0, down: 4, a: {x: 0}}); - c.insert({up: 3, down: 3, selected: 1, a: {x: 2.5}}); - c.insert({up: 4, down: 0, selected: 3, a: {x: 5}}); - - // Test that the the documents in "cursor" contain values with the name - // "field" running from 0 to the max value of that name in the collection. - var testCursorMatchesField = function (cursor, field) { - var fieldValues = []; - c.find().forEach(function (doc) { - if (_.has(doc, field)) - fieldValues.push(doc[field]); - }); - test.equal(_.pluck(cursor.fetch(), field), - _.range(_.max(fieldValues) + 1)); - }; - - testCursorMatchesField(c.find({}, {sort: {'a.x': 1}}), 'up'); - testCursorMatchesField(c.find({}, {sort: {'a.x': -1}}), 'down'); - testCursorMatchesField(c.find({'a.x': {$gt: 1}}, {sort: {'a.x': 1}}), - 'selected'); -}); - -Tinytest.add("minimongo - sort keys", function (test) { - var keyListToObject = function (keyList) { - var obj = {}; - _.each(keyList, function (key) { - obj[EJSON.stringify(key)] = true; - }); - return obj; - }; - - var testKeys = function (sortSpec, doc, expectedKeyList) { - var expectedKeys = keyListToObject(expectedKeyList); - var sorter = new Minimongo.Sorter(sortSpec); - - var actualKeyList = []; - sorter._generateKeysFromDoc(doc, function (key) { - actualKeyList.push(key); - }); - var actualKeys = keyListToObject(actualKeyList); - test.equal(actualKeys, expectedKeys); - }; - - var testParallelError = function (sortSpec, doc) { - var sorter = new Minimongo.Sorter(sortSpec); - test.throws(function () { - sorter._generateKeysFromDoc(doc, function (){}); - }, /parallel arrays/); - }; - - // Just non-array fields. - testKeys({'a.x': 1, 'a.y': 1}, - {a: {x: 0, y: 5}}, - [[0,5]]); - - // Ensure that we don't get [0,3] and [1,5]. - testKeys({'a.x': 1, 'a.y': 1}, - {a: [{x: 0, y: 5}, {x: 1, y: 3}]}, - [[0,5], [1,3]]); - - // Ensure we can combine "array fields" with "non-array fields". - testKeys({'a.x': 1, 'a.y': 1, b: -1}, - {a: [{x: 0, y: 5}, {x: 1, y: 3}], b: 42}, - [[0,5,42], [1,3,42]]); - testKeys({b: -1, 'a.x': 1, 'a.y': 1}, - {a: [{x: 0, y: 5}, {x: 1, y: 3}], b: 42}, - [[42,0,5], [42,1,3]]); - testKeys({'a.x': 1, b: -1, 'a.y': 1}, - {a: [{x: 0, y: 5}, {x: 1, y: 3}], b: 42}, - [[0,42,5], [1,42,3]]); - testKeys({a: 1, b: 1}, - {a: [1, 2, 3], b: 42}, - [[1,42], [2,42], [3,42]]); - - // Don't support multiple arrays at the same level. - testParallelError({a: 1, b: 1}, - {a: [1, 2, 3], b: [42]}); - - // We are MORE STRICT than Mongo here; Mongo supports this! - // XXX support this too #NestedArraySort - testParallelError({'a.x': 1, 'a.y': 1}, - {a: [{x: 1, y: [2, 3]}, - {x: 2, y: [4, 5]}]}); -}); - -Tinytest.add("minimongo - sort key filter", function (test) { - var testOrder = function (sortSpec, selector, doc1, doc2) { - var matcher = new Minimongo.Matcher(selector); - var sorter = new Minimongo.Sorter(sortSpec, {matcher: matcher}); - var comparator = sorter.getComparator(); - var comparison = comparator(doc1, doc2); - test.isTrue(comparison < 0); - }; - - testOrder({'a.x': 1}, {'a.x': {$gt: 1}}, - {a: {x: 3}}, - {a: {x: [1, 4]}}); - testOrder({'a.x': 1}, {'a.x': {$gt: 0}}, - {a: {x: [1, 4]}}, - {a: {x: 3}}); - - var keyCompatible = function (sortSpec, selector, key, compatible) { - var matcher = new Minimongo.Matcher(selector); - var sorter = new Minimongo.Sorter(sortSpec, {matcher: matcher}); - var actual = sorter._keyCompatibleWithSelector(key); - test.equal(actual, compatible); - }; - - keyCompatible({a: 1}, {a: 5}, [5], true); - keyCompatible({a: 1}, {a: 5}, [8], false); - keyCompatible({a: 1}, {a: {x: 5}}, [{x: 5}], true); - keyCompatible({a: 1}, {a: {x: 5}}, [{x: 5, y: 9}], false); - keyCompatible({'a.x': 1}, {a: {x: 5}}, [5], true); - // To confirm this: - // > db.x.insert({_id: "q", a: [{x:1}, {x:5}], b: 2}) - // > db.x.insert({_id: "w", a: [{x:5}, {x:10}], b: 1}) - // > db.x.find({}).sort({'a.x': 1, b: 1}) - // { "_id" : "q", "a" : [ { "x" : 1 }, { "x" : 5 } ], "b" : 2 } - // { "_id" : "w", "a" : [ { "x" : 5 }, { "x" : 10 } ], "b" : 1 } - // > db.x.find({a: {x:5}}).sort({'a.x': 1, b: 1}) - // { "_id" : "q", "a" : [ { "x" : 1 }, { "x" : 5 } ], "b" : 2 } - // { "_id" : "w", "a" : [ { "x" : 5 }, { "x" : 10 } ], "b" : 1 } - // > db.x.find({'a.x': 5}).sort({'a.x': 1, b: 1}) - // { "_id" : "w", "a" : [ { "x" : 5 }, { "x" : 10 } ], "b" : 1 } - // { "_id" : "q", "a" : [ { "x" : 1 }, { "x" : 5 } ], "b" : 2 } - // ie, only the last one manages to trigger the key compatibility code, - // not the previous one. (The "b" sort is necessary because when the key - // compatibility code *does* kick in, both documents only end up with "5" - // for the first field as their only sort key, and we need to differentiate - // somehow...) - keyCompatible({'a.x': 1}, {a: {x: 5}}, [1], true); - keyCompatible({'a.x': 1}, {'a.x': 5}, [5], true); - keyCompatible({'a.x': 1}, {'a.x': 5}, [1], false); - - // Regex key check. - keyCompatible({a: 1}, {a: /^foo+/}, ['foo'], true); - keyCompatible({a: 1}, {a: /^foo+/}, ['foooo'], true); - keyCompatible({a: 1}, {a: /^foo+/}, ['foooobar'], true); - keyCompatible({a: 1}, {a: /^foo+/}, ['afoooo'], false); - keyCompatible({a: 1}, {a: /^foo+/}, [''], false); - keyCompatible({a: 1}, {a: {$regex: "^foo+"}}, ['foo'], true); - keyCompatible({a: 1}, {a: {$regex: "^foo+"}}, ['foooo'], true); - keyCompatible({a: 1}, {a: {$regex: "^foo+"}}, ['foooobar'], true); - keyCompatible({a: 1}, {a: {$regex: "^foo+"}}, ['afoooo'], false); - keyCompatible({a: 1}, {a: {$regex: "^foo+"}}, [''], false); - - keyCompatible({a: 1}, {a: /^foo+/i}, ['foo'], true); - // Key compatibility check appears to be turned off for regexps with flags. - keyCompatible({a: 1}, {a: /^foo+/i}, ['bar'], true); - keyCompatible({a: 1}, {a: /^foo+/m}, ['bar'], true); - keyCompatible({a: 1}, {a: {$regex: "^foo+", $options: "i"}}, ['bar'], true); - keyCompatible({a: 1}, {a: {$regex: "^foo+", $options: "m"}}, ['bar'], true); - - // Multiple keys! - keyCompatible({a: 1, b: 1, c: 1}, - {a: {$gt: 5}, c: {$lt: 3}}, [6, "bla", 2], true); - keyCompatible({a: 1, b: 1, c: 1}, - {a: {$gt: 5}, c: {$lt: 3}}, [6, "bla", 4], false); - keyCompatible({a: 1, b: 1, c: 1}, - {a: {$gt: 5}, c: {$lt: 3}}, [3, "bla", 1], false); - // No filtering is done (ie, all keys are compatible) if the first key isn't - // constrained. - keyCompatible({a: 1, b: 1, c: 1}, - {c: {$lt: 3}}, [3, "bla", 4], true); -}); - -Tinytest.add("minimongo - sort function", function (test) { - var c = new LocalCollection(); - - c.insert({a: 1}); - c.insert({a: 10}); - c.insert({a: 5}); - c.insert({a: 7}); - c.insert({a: 2}); - c.insert({a: 4}); - c.insert({a: 3}); - - var sortFunction = function (doc1, doc2) { - return doc2.a - doc1.a; - }; - - test.equal(c.find({}, {sort: sortFunction}).fetch(), c.find({}).fetch().sort(sortFunction)); - test.notEqual(c.find({}).fetch(), c.find({}).fetch().sort(sortFunction)); - test.equal(c.find({}, {sort: {a: -1}}).fetch(), c.find({}).fetch().sort(sortFunction)); -}); - -Tinytest.add("minimongo - binary search", function (test) { - var forwardCmp = function (a, b) { - return a - b; - }; - - var backwardCmp = function (a, b) { - return -1 * forwardCmp(a, b); - }; - - var checkSearch = function (cmp, array, value, expected, message) { - var actual = LocalCollection._binarySearch(cmp, array, value); - if (expected != actual) { - test.fail({type: "minimongo-binary-search", - message: message + " : Expected index " + expected + - " but had " + actual - }); - } - }; - - var checkSearchForward = function (array, value, expected, message) { - checkSearch(forwardCmp, array, value, expected, message); - }; - var checkSearchBackward = function (array, value, expected, message) { - checkSearch(backwardCmp, array, value, expected, message); - }; - - checkSearchForward([1, 2, 5, 7], 4, 2, "Inner insert"); - checkSearchForward([1, 2, 3, 4], 3, 3, "Inner insert, equal value"); - checkSearchForward([1, 2, 5], 4, 2, "Inner insert, odd length"); - checkSearchForward([1, 3, 5, 6], 9, 4, "End insert"); - checkSearchForward([1, 3, 5, 6], 0, 0, "Beginning insert"); - checkSearchForward([1], 0, 0, "Single array, less than."); - checkSearchForward([1], 1, 1, "Single array, equal."); - checkSearchForward([1], 2, 1, "Single array, greater than."); - checkSearchForward([], 1, 0, "Empty array"); - checkSearchForward([1, 1, 1, 2, 2, 2, 2], 1, 3, "Highly degenerate array, lower"); - checkSearchForward([1, 1, 1, 2, 2, 2, 2], 2, 7, "Highly degenerate array, upper"); - checkSearchForward([2, 2, 2, 2, 2, 2, 2], 1, 0, "Highly degenerate array, lower"); - checkSearchForward([2, 2, 2, 2, 2, 2, 2], 2, 7, "Highly degenerate array, equal"); - checkSearchForward([2, 2, 2, 2, 2, 2, 2], 3, 7, "Highly degenerate array, upper"); - - checkSearchBackward([7, 5, 2, 1], 4, 2, "Backward: Inner insert"); - checkSearchBackward([4, 3, 2, 1], 3, 2, "Backward: Inner insert, equal value"); - checkSearchBackward([5, 2, 1], 4, 1, "Backward: Inner insert, odd length"); - checkSearchBackward([6, 5, 3, 1], 9, 0, "Backward: Beginning insert"); - checkSearchBackward([6, 5, 3, 1], 0, 4, "Backward: End insert"); - checkSearchBackward([1], 0, 1, "Backward: Single array, less than."); - checkSearchBackward([1], 1, 1, "Backward: Single array, equal."); - checkSearchBackward([1], 2, 0, "Backward: Single array, greater than."); - checkSearchBackward([], 1, 0, "Backward: Empty array"); - checkSearchBackward([2, 2, 2, 2, 1, 1, 1], 1, 7, "Backward: Degenerate array, lower"); - checkSearchBackward([2, 2, 2, 2, 1, 1, 1], 2, 4, "Backward: Degenerate array, upper"); - checkSearchBackward([2, 2, 2, 2, 2, 2, 2], 1, 7, "Backward: Highly degenerate array, upper"); - checkSearchBackward([2, 2, 2, 2, 2, 2, 2], 2, 7, "Backward: Highly degenerate array, upper"); - checkSearchBackward([2, 2, 2, 2, 2, 2, 2], 3, 0, "Backward: Highly degenerate array, upper"); -}); - -Tinytest.add("minimongo - modify", function (test) { - var modifyWithQuery = function (doc, query, mod, expected) { - var coll = new LocalCollection; - coll.insert(doc); - // The query is relevant for 'a.$.b'. - coll.update(query, mod); - var actual = coll.findOne(); - - if (!expected._id) { - delete actual._id; // added by insert - } - - if (typeof expected === "function") { - expected(actual, EJSON.stringify({input: doc, mod: mod})); - } else { - test.equal(actual, expected, EJSON.stringify({input: doc, mod: mod})); - } - }; - var modify = function (doc, mod, expected) { - modifyWithQuery(doc, {}, mod, expected); - }; - var exceptionWithQuery = function (doc, query, mod) { - var coll = new LocalCollection; - coll.insert(doc); - test.throws(function () { - coll.update(query, mod); - }); - }; - var exception = function (doc, mod) { - exceptionWithQuery(doc, {}, mod); - }; - - var upsert = function (query, mod, expected) { - var coll = new LocalCollection; - - var result = coll.upsert(query, mod); - - var actual = coll.findOne(); - - if (expected._id) { - test.equal(result.insertedId, expected._id); - } - else { - delete actual._id; - } - - test.equal(actual, expected); - }; - - var upsertUpdate = function (initialDoc, query, mod, expected) { - var coll = new LocalCollection; - - coll.insert(initialDoc); - var result = coll.upsert(query, mod); - - var actual = coll.findOne(); - - if (!expected._id) { - delete actual._id; - } - - test.equal(actual, expected); - } - - var upsertException = function (query, mod) { - var coll = new LocalCollection; - test.throws(function(){ - coll.upsert(query, mod); + // transform functions must return objects + const invalidObjects = [ + 'asdf', new MongoID.ObjectID(), false, null, true, + 27, [123], /adsf/, new Date, () => {}, undefined, + ]; + invalidObjects.forEach(invalidObject => { + const wrapped = wrap(() => invalidObject); + test.throws(() => { + wrapped({_id: 'asdf'}); }); - }; - - // document replacement - modify({}, {}, {}); - modify({a: 12}, {}, {}); // tested against mongodb - modify({a: 12}, {a: 13}, {a:13}); - modify({a: 12, b: 99}, {a: 13}, {a:13}); - exception({a: 12}, {a: 13, $set: {b: 13}}); - exception({a: 12}, {$set: {b: 13}, a: 13}); - - exception({a: 12}, {$a: 13}); //invalid operator - exception({a: 12}, {b:{$a: 13}}); - exception({a: 12}, {b:{'a.b': 13}}); - exception({a: 12}, {b:{'\0a': 13}}); - - // keys - modify({}, {$set: {'a': 12}}, {a: 12}); - modify({}, {$set: {'a.b': 12}}, {a: {b: 12}}); - modify({}, {$set: {'a.b.c': 12}}, {a: {b: {c: 12}}}); - modify({a: {d: 99}}, {$set: {'a.b.c': 12}}, {a: {d: 99, b: {c: 12}}}); - modify({}, {$set: {'a.b.3.c': 12}}, {a: {b: {3: {c: 12}}}}); - modify({a: {b: []}}, {$set: {'a.b.3.c': 12}}, { - a: {b: [null, null, null, {c: 12}]}}); - exception({a: [null, null, null]}, {$set: {'a.1.b': 12}}); - exception({a: [null, 1, null]}, {$set: {'a.1.b': 12}}); - exception({a: [null, "x", null]}, {$set: {'a.1.b': 12}}); - exception({a: [null, [], null]}, {$set: {'a.1.b': 12}}); - modify({a: [null, null, null]}, {$set: {'a.3.b': 12}}, { - a: [null, null, null, {b: 12}]}); - exception({a: []}, {$set: {'a.b': 12}}); - exception({a: 12}, {$set: {'a.b': 99}}); // tested on mongo - exception({a: 'x'}, {$set: {'a.b': 99}}); - exception({a: true}, {$set: {'a.b': 99}}); - exception({a: null}, {$set: {'a.b': 99}}); - modify({a: {}}, {$set: {'a.3': 12}}, {a: {'3': 12}}); - modify({a: []}, {$set: {'a.3': 12}}, {a: [null, null, null, 12]}); - exception({}, {$set: {'': 12}}); // tested on mongo - exception({}, {$set: {'.': 12}}); // tested on mongo - exception({}, {$set: {'a.': 12}}); // tested on mongo - exception({}, {$set: {'. ': 12}}); // tested on mongo - exception({}, {$inc: {'... ': 12}}); // tested on mongo - exception({}, {$set: {'a..b': 12}}); // tested on mongo - modify({a: [1,2,3]}, {$set: {'a.01': 99}}, {a: [1, 99, 3]}); - modify({a: [1,{a: 98},3]}, {$set: {'a.01.b': 99}}, {a: [1,{a:98, b: 99},3]}); - modify({}, {$set: {'2.a.b': 12}}, {'2': {'a': {'b': 12}}}); // tested - exception({x: []}, {$set: {'x.2..a': 99}}); - modify({x: [null, null]}, {$set: {'x.2.a': 1}}, {x: [null, null, {a: 1}]}); - exception({x: [null, null]}, {$set: {'x.1.a': 1}}); - - // a.$.b - modifyWithQuery({a: [{x: 2}, {x: 4}]}, {'a.x': 4}, {$set: {'a.$.z': 9}}, - {a: [{x: 2}, {x: 4, z: 9}]}); - exception({a: [{x: 2}, {x: 4}]}, {$set: {'a.$.z': 9}}); - exceptionWithQuery({a: [{x: 2}, {x: 4}], b: 5}, {b: 5}, {$set: {'a.$.z': 9}}); - // can't have two $ - exceptionWithQuery({a: [{x: [2]}]}, {'a.x': 2}, {$set: {'a.$.x.$': 9}}); - modifyWithQuery({a: [5, 6, 7]}, {a: 6}, {$set: {'a.$': 9}}, {a: [5, 9, 7]}); - modifyWithQuery({a: [{b: [{c: 9}, {c: 10}]}, {b: {c: 11}}]}, {'a.b.c': 10}, - {$unset: {'a.$.b': 1}}, {a: [{}, {b: {c: 11}}]}); - modifyWithQuery({a: [{b: [{c: 9}, {c: 10}]}, {b: {c: 11}}]}, {'a.b.c': 11}, - {$unset: {'a.$.b': 1}}, - {a: [{b: [{c: 9}, {c: 10}]}, {}]}); - modifyWithQuery({a: [1]}, {'a.0': 1}, {$set: {'a.$': 5}}, {a: [5]}); - modifyWithQuery({a: [9]}, {a: {$mod: [2, 1]}}, {$set: {'a.$': 5}}, {a: [5]}); - // Negatives don't set '$'. - exceptionWithQuery({a: [1]}, {$not: {a: 2}}, {$set: {'a.$': 5}}); - exceptionWithQuery({a: [1]}, {'a.0': {$ne: 2}}, {$set: {'a.$': 5}}); - // One $or clause works. - modifyWithQuery({a: [{x: 2}, {x: 4}]}, - {$or: [{'a.x': 4}]}, {$set: {'a.$.z': 9}}, - {a: [{x: 2}, {x: 4, z: 9}]}); - // More $or clauses throw. - exceptionWithQuery({a: [{x: 2}, {x: 4}]}, - {$or: [{'a.x': 4}, {'a.x': 4}]}, - {$set: {'a.$.z': 9}}); - // $and uses the last one. - modifyWithQuery({a: [{x: 1}, {x: 3}]}, - {$and: [{'a.x': 1}, {'a.x': 3}]}, - {$set: {'a.$.x': 5}}, - {a: [{x: 1}, {x: 5}]}); - modifyWithQuery({a: [{x: 1}, {x: 3}]}, - {$and: [{'a.x': 3}, {'a.x': 1}]}, - {$set: {'a.$.x': 5}}, - {a: [{x: 5}, {x: 3}]}); - // Same goes for the implicit AND of a document selector. - modifyWithQuery({a: [{x: 1}, {y: 3}]}, - {'a.x': 1, 'a.y': 3}, - {$set: {'a.$.z': 5}}, - {a: [{x: 1}, {y: 3, z: 5}]}); - modifyWithQuery({a: [{x: 1}, {y: 1}, {x: 1, y: 1}]}, - {a: {$elemMatch: {x: 1, y: 1}}}, - {$set: {'a.$.x': 2}}, - {a: [{x: 1}, {y: 1}, {x: 2, y: 1}]}); - modifyWithQuery({a: [{b: [{x: 1}, {y: 1}, {x: 1, y: 1}]}]}, - {'a.b': {$elemMatch: {x: 1, y: 1}}}, - {$set: {'a.$.b': 3}}, - {a: [{b: 3}]}); - // with $near, make sure it does not find the closest one (#3599) - modifyWithQuery({a: []}, - {'a.b': {$near: [5, 5]}}, - {$set: {'a.$.b': 'k'}}, - {"a":[]}); - modifyWithQuery({a: [{b: [ [3,3], [4,4] ]}]}, - {'a.b': {$near: [5, 5]}}, - {$set: {'a.$.b': 'k'}}, - {"a":[{"b":"k"}]}); - modifyWithQuery({a: [{b: [1,1]}, - {b: [ [3,3], [4,4] ]}, - {b: [9,9]}]}, - {'a.b': {$near: [5, 5]}}, - {$set: {'a.$.b': 'k'}}, - {"a":[{"b":"k"},{"b":[[3,3],[4,4]]},{"b":[9,9]}]}); - modifyWithQuery({a: [{b: [1,1]}, - {b: [ [3,3], [4,4] ]}, - {b: [9,9]}]}, - {'a.b': {$near: [9, 9], $maxDistance: 1}}, - {$set: {'a.$.b': 'k'}}, - {"a":[{"b":"k"},{"b":[[3,3],[4,4]]},{"b":[9,9]}]}); - modifyWithQuery({a: [{b: [1,1]}, - {b: [ [3,3], [4,4] ]}, - {b: [9,9]}]}, - {'a.b': {$near: [9, 9]}}, - {$set: {'a.$.b': 'k'}}, - {"a":[{"b":"k"},{"b":[[3,3],[4,4]]},{"b":[9,9]}]}); - modifyWithQuery({a: [{b: [9,9]}, - {b: [ [3,3], [4,4] ]}, - {b: [9,9]}]}, - {'a.b': {$near: [9, 9]}}, - {$set: {'a.$.b': 'k'}}, - {"a":[{"b":"k"},{"b":[[3,3],[4,4]]},{"b":[9,9]}]}); - modifyWithQuery({a: [{b:[4,3]}, - {c: [1,1]}]}, - {'a.c': {$near: [1, 1]}}, - {$set: {'a.$.c': 'k'}}, - {"a":[{"c": "k", "b":[4,3]},{"c":[1,1]}]}); - modifyWithQuery({a: [{c: [9,9]}, - {b: [ [3,3], [4,4] ]}, - {b: [1,1]}]}, - {'a.b': {$near: [1, 1]}}, - {$set: {'a.$.b': 'k'}}, - {"a":[{"c": [9,9], "b":"k"},{"b": [ [3,3], [4,4]]},{"b":[1,1]}]}); - modifyWithQuery({a: [{c: [9,9], b:[4,3]}, - {b: [ [3,3], [4,4] ]}, - {b: [1,1]}]}, - {'a.b': {$near: [1, 1]}}, - {$set: {'a.$.b': 'k'}}, - {"a":[{"c": [9,9], "b":"k"},{"b": [ [3,3], [4,4]]},{"b":[1,1]}]}); - - // $inc - modify({a: 1, b: 2}, {$inc: {a: 10}}, {a: 11, b: 2}); - modify({a: 1, b: 2}, {$inc: {c: 10}}, {a: 1, b: 2, c: 10}); - exception({a: 1}, {$inc: {a: '10'}}); - exception({a: 1}, {$inc: {a: true}}); - exception({a: 1}, {$inc: {a: [10]}}); - exception({a: '1'}, {$inc: {a: 10}}); - exception({a: [1]}, {$inc: {a: 10}}); - exception({a: {}}, {$inc: {a: 10}}); - exception({a: false}, {$inc: {a: 10}}); - exception({a: null}, {$inc: {a: 10}}); - modify({a: [1, 2]}, {$inc: {'a.1': 10}}, {a: [1, 12]}); - modify({a: [1, 2]}, {$inc: {'a.2': 10}}, {a: [1, 2, 10]}); - modify({a: [1, 2]}, {$inc: {'a.3': 10}}, {a: [1, 2, null, 10]}); - modify({a: {b: 2}}, {$inc: {'a.b': 10}}, {a: {b: 12}}); - modify({a: {b: 2}}, {$inc: {'a.c': 10}}, {a: {b: 2, c: 10}}); - exception({}, {$inc: {_id: 1}}); - - // $currentDate - modify({}, {$currentDate: {a: true}}, (result, msg) => { test.instanceOf(result.a,Date,msg) }); - modify({}, {$currentDate: {a: {$type: "date"}}}, (result, msg) => { test.instanceOf(result.a,Date,msg) }); - exception({}, {$currentDate: {a: false}}); - exception({}, {$currentDate: {a: {}}}); - exception({}, {$currentDate: {a: {$type: "timestamp"}}}); - - // $min - modify({a: 1, b: 2}, {$min: {b: 1}}, {a: 1, b: 1}); - modify({a: 1, b: 2}, {$min: {b: 3}}, {a: 1, b: 2}); - modify({a: 1, b: 2}, {$min: {c: 10}}, {a: 1, b: 2, c: 10}); - exception({a: 1}, {$min: {a: '10'}}); - exception({a: 1}, {$min: {a: true}}); - exception({a: 1}, {$min: {a: [10]}}); - exception({a: '1'}, {$min: {a: 10}}); - exception({a: [1]}, {$min: {a: 10}}); - exception({a: {}}, {$min: {a: 10}}); - exception({a: false}, {$min: {a: 10}}); - exception({a: null}, {$min: {a: 10}}); - modify({a: [1, 2]}, {$min: {'a.1': 1}}, {a: [1, 1]}); - modify({a: [1, 2]}, {$min: {'a.1': 3}}, {a: [1, 2]}); - modify({a: [1, 2]}, {$min: {'a.2': 10}}, {a: [1, 2, 10]}); - modify({a: [1, 2]}, {$min: {'a.3': 10}}, {a: [1, 2, null, 10]}); - modify({a: {b: 2}}, {$min: {'a.b': 1}}, {a: {b: 1}}); - modify({a: {b: 2}}, {$min: {'a.c': 10}}, {a: {b: 2, c: 10}}); - exception({}, {$min: {_id: 1}}); - - // $max - modify({a: 1, b: 2}, {$max: {b: 1}}, {a: 1, b: 2}); - modify({a: 1, b: 2}, {$max: {b: 3}}, {a: 1, b: 3}); - modify({a: 1, b: 2}, {$max: {c: 10}}, {a: 1, b: 2, c: 10}); - exception({a: 1}, {$max: {a: '10'}}); - exception({a: 1}, {$max: {a: true}}); - exception({a: 1}, {$max: {a: [10]}}); - exception({a: '1'}, {$max: {a: 10}}); - exception({a: [1]}, {$max: {a: 10}}); - exception({a: {}}, {$max: {a: 10}}); - exception({a: false}, {$max: {a: 10}}); - exception({a: null}, {$max: {a: 10}}); - modify({a: [1, 2]}, {$max: {'a.1': 3}}, {a: [1, 3]}); - modify({a: [1, 2]}, {$max: {'a.1': 1}}, {a: [1, 2]}); - modify({a: [1, 2]}, {$max: {'a.2': 10}}, {a: [1, 2, 10]}); - modify({a: [1, 2]}, {$max: {'a.3': 10}}, {a: [1, 2, null, 10]}); - modify({a: {b: 2}}, {$max: {'a.b': 3}}, {a: {b: 3}}); - modify({a: {b: 2}}, {$max: {'a.c': 10}}, {a: {b: 2, c: 10}}); - exception({}, {$max: {_id: 1}}); - - // $set - modify({a: 1, b: 2}, {$set: {a: 10}}, {a: 10, b: 2}); - modify({a: 1, b: 2}, {$set: {c: 10}}, {a: 1, b: 2, c: 10}); - modify({a: 1, b: 2}, {$set: {a: {c: 10}}}, {a: {c: 10}, b: 2}); - modify({a: [1, 2], b: 2}, {$set: {a: [3, 4]}}, {a: [3, 4], b: 2}); - modify({a: [1, 2, 3], b: 2}, {$set: {'a.1': [3, 4]}}, - {a: [1, [3, 4], 3], b:2}); - modify({a: [1], b: 2}, {$set: {'a.1': 9}}, {a: [1, 9], b: 2}); - modify({a: [1], b: 2}, {$set: {'a.2': 9}}, {a: [1, null, 9], b: 2}); - modify({a: {b: 1}}, {$set: {'a.c': 9}}, {a: {b: 1, c: 9}}); - modify({}, {$set: {'x._id': 4}}, {x: {_id: 4}}); - - // Changing _id is disallowed - exception({}, {$set: {_id: 4}}); - exception({_id: 1}, {$set: {_id: 4}}) - modify({_id: 4}, {$set: {_id: 4}}, {_id: 4}); // not-changing _id is not bad - - //restricted field names - exception({a:{}}, {$set:{a:{$a:1}}}); - exception({ a: {} }, { $set: { a: { c: - [{ b: { $a: 1 } }] } } }); - exception({a:{}}, {$set:{a:{'\0a':1}}}); - exception({a:{}}, {$set:{a:{'a.b':1}}}); - - // $unset - modify({}, {$unset: {a: 1}}, {}); - modify({a: 1}, {$unset: {a: 1}}, {}); - modify({a: 1, b: 2}, {$unset: {a: 1}}, {b: 2}); - modify({a: 1, b: 2}, {$unset: {a: 0}}, {b: 2}); - modify({a: 1, b: 2}, {$unset: {a: false}}, {b: 2}); - modify({a: 1, b: 2}, {$unset: {a: null}}, {b: 2}); - modify({a: 1, b: 2}, {$unset: {a: [1]}}, {b: 2}); - modify({a: 1, b: 2}, {$unset: {a: {}}}, {b: 2}); - modify({a: {b: 2, c: 3}}, {$unset: {'a.b': 1}}, {a: {c: 3}}); - modify({a: [1, 2, 3]}, {$unset: {'a.1': 1}}, {a: [1, null, 3]}); // tested - modify({a: [1, 2, 3]}, {$unset: {'a.2': 1}}, {a: [1, 2, null]}); // tested - modify({a: [1, 2, 3]}, {$unset: {'a.x': 1}}, {a: [1, 2, 3]}); // tested - modify({a: {b: 1}}, {$unset: {'a.b.c.d': 1}}, {a: {b: 1}}); - modify({a: {b: 1}}, {$unset: {'a.x.c.d': 1}}, {a: {b: 1}}); - modify({a: {b: {c: 1}}}, {$unset: {'a.b.c': 1}}, {a: {b: {}}}); - exception({}, {$unset: {_id: 1}}); - - // $push - modify({}, {$push: {a: 1}}, {a: [1]}); - modify({a: []}, {$push: {a: 1}}, {a: [1]}); - modify({a: [1]}, {$push: {a: 2}}, {a: [1, 2]}); - exception({a: true}, {$push: {a: 1}}); - modify({a: [1]}, {$push: {a: [2]}}, {a: [1, [2]]}); - modify({a: []}, {$push: {'a.1': 99}}, {a: [null, [99]]}); // tested - modify({a: {}}, {$push: {'a.x': 99}}, {a: {x: [99]}}); - modify({}, {$push: {a: {$each: [1, 2, 3]}}}, - {a: [1, 2, 3]}); - modify({a: []}, {$push: {a: {$each: [1, 2, 3]}}}, - {a: [1, 2, 3]}); - modify({a: [true]}, {$push: {a: {$each: [1, 2, 3]}}}, - {a: [true, 1, 2, 3]}); - modify({a: [true]}, {$push: {a: {$each: [1, 2, 3], $slice: -2}}}, - {a: [2, 3]}); - modify({a: [false, true]}, {$push: {a: {$each: [1], $slice: -2}}}, - {a: [true, 1]}); - modify( - {a: [{x: 3}, {x: 1}]}, - {$push: {a: { - $each: [{x: 4}, {x: 2}], - $slice: -2, - $sort: {x: 1} - }}}, - {a: [{x: 3}, {x: 4}]}); - modify({}, {$push: {a: {$each: [1, 2, 3], $slice: 0}}}, {a: []}); - modify({a: [1, 2]}, {$push: {a: {$each: [1, 2, 3], $slice: 0}}}, {a: []}); - // $push with $position modifier - // No negative number for $position - exception({a: []}, {$push: {a: {$each: [0], $position: -1}}}); - modify({a: [1, 2]}, {$push: {a: {$each: [0], $position: 0}}}, - {a: [0, 1, 2]}); - modify({a: [1, 2]}, {$push: {a: {$each: [-1, 0], $position: 0}}}, - {a: [-1, 0, 1, 2]}); - modify({a: [1, 3]}, {$push: {a: {$each: [2], $position: 1}}}, {a: [1, 2, 3]}); - modify({a: [1, 4]}, {$push: {a: {$each: [2, 3], $position: 1}}}, - {a: [1, 2, 3, 4]}); - modify({a: [1, 2]}, {$push: {a: {$each: [3], $position: 3}}}, {a: [1, 2, 3]}); - modify({a: [1, 2]}, {$push: {a: {$each: [3], $position: 99}}}, - {a: [1, 2, 3]}); - modify({a: [1, 2]}, {$push: {a: {$each: [3], $position: 99, $slice: -2}}}, - {a: [2, 3]}); - modify( - {a: [{x: 1}, {x: 2}]}, - {$push: {a: {$each: [{x: 3}], $position: 0, $sort: {x: 1}, $slice: -3}}}, - {a: [{x: 1}, {x: 2}, {x: 3}]} - ); - modify( - {a: [{x: 1}, {x: 2}]}, - {$push: {a: {$each: [{x: 3}], $position: 0, $sort: {x: 1}, $slice: 0}}}, - {a: []} - ); - //restricted field names - exception({}, {$push: {$a: 1}}); - exception({}, {$push: {'\0a': 1}}); - exception({}, {$push: {a: {$a:1}}}); - exception({}, {$push: {a: {$each: [{$a:1}]}}}); - exception({}, {$push: {a: {$each: [{"a.b":1}]}}}); - exception({}, {$push: {a: {$each: [{'\0a':1}]}}}); - modify({}, {$push: {a: {$each: [{'':1}]}}}, {a: [ { '': 1 } ]}); - modify({}, {$push: {a: {$each: [{' ':1}]}}}, {a: [ { ' ': 1 } ]}); - exception({}, {$push: {a: {$each: [{'.':1}]}}}); - - // #issue 5167 - // $push $slice with positive numbers - modify({}, {$push: {a: {$each: [], $slice: 5}}}, {a:[]}); - modify({a:[1,2,3]}, {$push: {a: {$each: [], $slice: 1}}}, {a:[1]}); - modify({a:[1,2,3]}, {$push: {a: {$each: [4,5], $slice: 1}}}, {a:[1]}); - modify({a:[1,2,3]}, {$push: {a: {$each: [4,5], $slice: 2}}}, {a:[1,2]}); - modify({a:[1,2,3]}, {$push: {a: {$each: [4,5], $slice: 4}}}, {a:[1,2,3,4]}); - modify({a:[1,2,3]}, {$push: {a: {$each: [4,5], $slice: 5}}}, {a:[1,2,3,4,5]}); - modify({a:[1,2,3]}, {$push: {a: {$each: [4,5], $slice: 10}}}, {a:[1,2,3,4,5]}); - - - // $pushAll - modify({}, {$pushAll: {a: [1]}}, {a: [1]}); - modify({a: []}, {$pushAll: {a: [1]}}, {a: [1]}); - modify({a: [1]}, {$pushAll: {a: [2]}}, {a: [1, 2]}); - modify({}, {$pushAll: {a: [1, 2]}}, {a: [1, 2]}); - modify({a: []}, {$pushAll: {a: [1, 2]}}, {a: [1, 2]}); - modify({a: [1]}, {$pushAll: {a: [2, 3]}}, {a: [1, 2, 3]}); - modify({}, {$pushAll: {a: []}}, {a: []}); - modify({a: []}, {$pushAll: {a: []}}, {a: []}); - modify({a: [1]}, {$pushAll: {a: []}}, {a: [1]}); - exception({a: true}, {$pushAll: {a: [1]}}); - exception({a: []}, {$pushAll: {a: 1}}); - modify({a: []}, {$pushAll: {'a.1': [99]}}, {a: [null, [99]]}); - modify({a: []}, {$pushAll: {'a.1': []}}, {a: [null, []]}); - modify({a: {}}, {$pushAll: {'a.x': [99]}}, {a: {x: [99]}}); - modify({a: {}}, {$pushAll: {'a.x': []}}, {a: {x: []}}); - exception({a: [1]}, {$pushAll: {a: [{$a:1}]}}); - exception({a: [1]}, {$pushAll: {a: [{'\0a':1}]}}); - exception({a: [1]}, {$pushAll: {a: [{"a.b":1}]}}); - - // $addToSet - modify({}, {$addToSet: {a: 1}}, {a: [1]}); - modify({a: []}, {$addToSet: {a: 1}}, {a: [1]}); - modify({a: [1]}, {$addToSet: {a: 2}}, {a: [1, 2]}); - modify({a: [1, 2]}, {$addToSet: {a: 1}}, {a: [1, 2]}); - modify({a: [1, 2]}, {$addToSet: {a: 2}}, {a: [1, 2]}); - modify({a: [1, 2]}, {$addToSet: {a: 3}}, {a: [1, 2, 3]}); - exception({a: true}, {$addToSet: {a: 1}}); - modify({a: [1]}, {$addToSet: {a: [2]}}, {a: [1, [2]]}); - modify({}, {$addToSet: {a: {x: 1}}}, {a: [{x: 1}]}); - modify({a: [{x: 1}]}, {$addToSet: {a: {x: 1}}}, {a: [{x: 1}]}); - modify({a: [{x: 1}]}, {$addToSet: {a: {x: 2}}}, {a: [{x: 1}, {x: 2}]}); - modify({a: [{x: 1, y: 2}]}, {$addToSet: {a: {x: 1, y: 2}}}, - {a: [{x: 1, y: 2}]}); - modify({a: [{x: 1, y: 2}]}, {$addToSet: {a: {y: 2, x: 1}}}, - {a: [{x: 1, y: 2}, {y: 2, x: 1}]}); - modify({a: [1, 2]}, {$addToSet: {a: {$each: [3, 1, 4]}}}, {a: [1, 2, 3, 4]}); - modify({}, {$addToSet: {a: {$each: []}}}, {a: []}); - modify({}, {$addToSet: {a: {$each: [1]}}}, {a: [1]}); - modify({a: []}, {$addToSet: {'a.1': 99}}, {a: [null, [99]]}); - modify({a: {}}, {$addToSet: {'a.x': 99}}, {a: {x: [99]}}); - - // invalid field names - exception({}, {$addToSet: {a: {$b:1}}}); - exception({}, {$addToSet: {a: {"a.b":1}}}); - exception({}, {$addToSet: {a: {"a.":1}}}); - exception({}, {$addToSet: {a: {'\u0000a':1}}}); - exception({a: [1, 2]}, {$addToSet: {a:{$each: [3, 1, {$a:1}]}}}); - exception({a: [1, 2]}, {$addToSet: {a:{$each: [3, 1, {'\0a':1}]}}}); - exception({a: [1, 2]}, {$addToSet: {a:{$each: [3, 1, [{$a:1}]]}}}); - exception({a: [1, 2]}, {$addToSet: {a:{$each: [3, 1, [{b:{c:[{a:1},{"d.s":2}]}}]]}}}); - exception({a: [1, 2]}, {$addToSet: {a:{b: [3, 1, [{b:{c:[{a:1},{"d.s":2}]}}]]}}}); - //$each is first element and thus an operator - modify({a: [1, 2]}, {$addToSet: {a: {$each: [3, 1, 4], b: 12}}},{a: [ 1, 2, 3, 4 ]}); - // this should fail because $each is now a field name (not first in object) and thus invalid field name with $ - exception({a: [1, 2]}, {$addToSet: {a: {b: 12, $each: [3, 1, 4]}}}); - - // $pop - modify({}, {$pop: {a: 1}}, {}); // tested - modify({}, {$pop: {a: -1}}, {}); // tested - modify({a: []}, {$pop: {a: 1}}, {a: []}); - modify({a: []}, {$pop: {a: -1}}, {a: []}); - modify({a: [1, 2, 3]}, {$pop: {a: 1}}, {a: [1, 2]}); - modify({a: [1, 2, 3]}, {$pop: {a: 10}}, {a: [1, 2]}); - modify({a: [1, 2, 3]}, {$pop: {a: .001}}, {a: [1, 2]}); - modify({a: [1, 2, 3]}, {$pop: {a: 0}}, {a: [1, 2]}); - modify({a: [1, 2, 3]}, {$pop: {a: "stuff"}}, {a: [1, 2]}); - modify({a: [1, 2, 3]}, {$pop: {a: -1}}, {a: [2, 3]}); - modify({a: [1, 2, 3]}, {$pop: {a: -10}}, {a: [2, 3]}); - modify({a: [1, 2, 3]}, {$pop: {a: -.001}}, {a: [2, 3]}); - exception({a: true}, {$pop: {a: 1}}); - exception({a: true}, {$pop: {a: -1}}); - modify({a: []}, {$pop: {'a.1': 1}}, {a: []}); // tested - modify({a: [1, [2, 3], 4]}, {$pop: {'a.1': 1}}, {a: [1, [2], 4]}); - modify({a: {}}, {$pop: {'a.x': 1}}, {a: {}}); // tested - modify({a: {x: [2, 3]}}, {$pop: {'a.x': 1}}, {a: {x: [2]}}); - - // $pull - modify({}, {$pull: {a: 1}}, {}); - modify({}, {$pull: {'a.x': 1}}, {}); - modify({a: {}}, {$pull: {'a.x': 1}}, {a: {}}); - exception({a: true}, {$pull: {a: 1}}); - modify({a: [2, 1, 2]}, {$pull: {a: 1}}, {a: [2, 2]}); - modify({a: [2, 1, 2]}, {$pull: {a: 2}}, {a: [1]}); - modify({a: [2, 1, 2]}, {$pull: {a: 3}}, {a: [2, 1, 2]}); - modify({a: [1, null, 2, null]}, {$pull: {a: null}}, {a: [1, 2]}); - modify({a: []}, {$pull: {a: 3}}, {a: []}); - modify({a: [[2], [2, 1], [3]]}, {$pull: {a: [2, 1]}}, - {a: [[2], [3]]}); // tested - modify({a: [{b: 1, c: 2}, {b: 2, c: 2}]}, {$pull: {a: {b: 1}}}, - {a: [{b: 2, c: 2}]}); - modify({a: [{b: 1, c: 2}, {b: 2, c: 2}]}, {$pull: {a: {c: 2}}}, - {a: []}); - // XXX implement this functionality! - // probably same refactoring as $elemMatch? - // modify({a: [1, 2, 3, 4]}, {$pull: {$gt: 2}}, {a: [1,2]}); fails! - - // $pullAll - modify({}, {$pullAll: {a: [1]}}, {}); - modify({a: [1, 2, 3]}, {$pullAll: {a: []}}, {a: [1, 2, 3]}); - modify({a: [1, 2, 3]}, {$pullAll: {a: [2]}}, {a: [1, 3]}); - modify({a: [1, 2, 3]}, {$pullAll: {a: [2, 1]}}, {a: [3]}); - modify({a: [1, 2, 3]}, {$pullAll: {a: [1, 2]}}, {a: [3]}); - modify({}, {$pullAll: {'a.b.c': [2]}}, {}); - exception({a: true}, {$pullAll: {a: [1]}}); - exception({a: [1, 2, 3]}, {$pullAll: {a: 1}}); - modify({x: [{a: 1}, {a: 1, b: 2}]}, {$pullAll: {x: [{a: 1}]}}, - {x: [{a: 1, b: 2}]}); - - // $rename - modify({}, {$rename: {a: 'b'}}, {}); - modify({a: [12]}, {$rename: {a: 'b'}}, {b: [12]}); - modify({a: {b: 12}}, {$rename: {a: 'c'}}, {c: {b: 12}}); - modify({a: {b: 12}}, {$rename: {'a.b': 'a.c'}}, {a: {c: 12}}); - modify({a: {b: 12}}, {$rename: {'a.b': 'x'}}, {a: {}, x: 12}); // tested - modify({a: {b: 12}}, {$rename: {'a.b': 'q.r'}}, {a: {}, q: {r: 12}}); - modify({a: {b: 12}}, {$rename: {'a.b': 'q.2.r'}}, {a: {}, q: {2: {r: 12}}}); - modify({a: {b: 12}, q: {}}, {$rename: {'a.b': 'q.2.r'}}, - {a: {}, q: {2: {r: 12}}}); - exception({a: {b: 12}, q: []}, {$rename: {'a.b': 'q.2'}}); // tested - exception({a: {b: 12}, q: []}, {$rename: {'a.b': 'q.2.r'}}); // tested - // These strange MongoDB behaviors throw. - // modify({a: {b: 12}, q: []}, {$rename: {'q.1': 'x'}}, - // {a: {b: 12}, x: []}); // tested - // modify({a: {b: 12}, q: []}, {$rename: {'q.1.j': 'x'}}, - // {a: {b: 12}, x: []}); // tested - exception({}, {$rename: {'a': 'a'}}); - exception({}, {$rename: {'a.b': 'a.b'}}); - modify({a: 12, b: 13}, {$rename: {a: 'b'}}, {b: 12}); - exception({a: [12]}, {$rename: {a: '$b'}}); - exception({a: [12]}, {$rename: {a: '\0a'}}); - - // $setOnInsert - modify({a: 0}, {$setOnInsert: {a: 12}}, {a: 0}); - upsert({a: 12}, {$setOnInsert: {b: 12}}, {a: 12, b: 12}); - upsert({a: 12}, {$setOnInsert: {_id: 'test'}}, {_id: 'test', a: 12}); - upsert({"a.b": 10}, {$setOnInsert: {a: {b: 10, c: 12}}}, {a: {b: 10, c: 12}}); - upsert({"a.b": 10}, {$setOnInsert: {c: 12}}, {a: {b: 10}, c: 12}); - upsert({"_id": 'test'}, {$setOnInsert: {c: 12}}, {_id: 'test', c: 12}); - upsert('test', {$setOnInsert: {c: 12}}, {_id: 'test', c: 12}); - upsertException({a: 0}, {$setOnInsert: {$a: 12}}); - upsertException({a: 0}, {$setOnInsert: {'\0a': 12}}); - upsert({a: 0}, {$setOnInsert: {b: {a:1}}}, {a:0, b:{a:1}}); - upsertException({a: 0}, {$setOnInsert: {b: {$a:1}}}); - upsertException({a: 0}, {$setOnInsert: {b: {'a.b':1}}}); - upsertException({a: 0}, {$setOnInsert: {b: {'\0a':1}}}); - - // Test for https://github.com/meteor/meteor/issues/8775. - upsert( - { a: { $exists: true }}, - { $setOnInsert: { a: 123 }}, - { a: 123 } - ); - - // Tests for https://github.com/meteor/meteor/issues/8794. - const testObjectId = new MongoID.ObjectID(); - upsert( - { _id: testObjectId }, - { $setOnInsert: { a: 123 } }, - { _id: testObjectId, a: 123 }, - ); - upsert( - { someOtherId: testObjectId }, - { $setOnInsert: { a: 123 } }, - { someOtherId: testObjectId, a: 123 }, - ); - upsert( - { a: { $eq: testObjectId } }, - { $setOnInsert: { a: 123 } }, - { a: 123 }, - ); - const testDate = new Date('2017-01-01'); - upsert( - { someDate: testDate }, - { $setOnInsert: { a: 123 } }, - { someDate: testDate, a: 123 }, - ); - upsert( - { - a: Object.create(null, { - $exists: { - writable: true, - configurable: true, - value: true - } - }), - }, - { $setOnInsert: { a: 123 } }, - { a: 123 }, - ); - upsert( - { foo: { $exists: true, $type: 2 }}, - { $setOnInsert: { bar: 'baz' } }, - { bar: 'baz' } - ); - upsert( - { foo: {} }, - { $setOnInsert: { bar: 'baz' } }, - { foo: {}, bar: 'baz' } - ); - - - // Tests for https://github.com/meteor/meteor/issues/8806 - - upsert({"a": {"b": undefined, "c": null}}, {"$set": {"c": "foo"}}, {"a": {"b": undefined, "c": null}, "c": "foo"}) - upsert({"a": {"$eq": "bar" }}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) - // $all with 1 statement is similar to $eq - upsert({"a": {"$all": ["bar"] }}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) - upsert({"a": {"$eq": "bar" }, "b": "baz"}, {"$set": {"c": "foo"}}, {"a": "bar", "b": "baz", "c": "foo"}) - - upsert({"a": {"$exists": true}}, {"$set": {"c": "foo"}}, {"c": "foo"}) - upsert({"a": {"$exists": true, "$eq": "foo"}}, {"$set": {"c": "foo"}}, {"a": "foo", "c": "foo"}) - upsert({"a": {"$gt": 3, "$eq": 2}}, {"$set": {"c": "foo"}}, {"a": 2, "c": "foo"}) - - // $and - upsert({"$and": [{"a": {"$eq": "bar"}}]}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) - upsert({"$and": [{"a": {"$all": ["bar"]}}]}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) - upsert({"$and": [{"a": {"$all": ["bar"]}}]}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) - - // $or with one statement is handled similar to $and - upsert({"$or": [{"a": "bar"}]}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) - - // $or with multiple statements is ignored - upsert({"$or": [{"a": "bar"}, {"b": "baz"}]}, {"$set": {"c": "foo"}}, {"c": "foo"}) - - // Negative logical operators are ignored - upsert({"$nor": [{"a": "bar"}]}, {"$set": {"c": "foo"}}, {"c": "foo"}) - - // Filter out empty objects after filtering out operators - upsert({"a": {"$exists": true}}, {"$set": {"c": "foo"}}, {"c": "foo"}) - - // But leave actual empty objects - upsert({"a": {}}, {"$set": {"c": "foo"}}, {"a": {}, "c": "foo"}) - - // Also filter out shorthand regexp notation - upsert({"a": /a/}, {"$set": {"c": "foo"}}, {"c": "foo"}) - - // Test nested fields - upsert({"$and": [{"a.a": "foo"}, {"$or": [{"a.b": "baz"}]}]}, {"$set": {"c": "foo"}}, {"a": {"a": "foo", "b": "baz"}, "c": "foo"}) - - // Test for https://github.com/meteor/meteor/issues/5294 - upsert({"a": {"$ne": 444}}, {"$push": {"a": 123}}, {"a": [123]}) - - // Mod takes precedence over query - upsert({"a": "foo"}, {"a": "bar"}, {"a": "bar"}) - upsert({"a": "foo"}, {"$set":{"a": "bar"}}, {"a": "bar"}) - - // Replacement can take _id from query - upsert({"_id": "foo", "foo": "bar"}, {"bar": "foo"}, {"_id": "foo", "bar": "foo"}) - - // Replacement update keeps _id - upsertUpdate({"_id": "foo", "bar": "baz"}, {"_id":"foo"}, {"bar": "crow"}, {"_id": "foo", "bar": "crow"}); - - // Nested fields don't work with literal objects - upsertException({"a": {}, "a.b": "foo"}, {}); - - // You can't have an ambiguious ID - upsertException({"_id":"foo"}, {"_id":"bar"}); - upsertException({"_id":"foo"}, {"$set":{"_id":"bar"}}); - - // You can't set the same field twice - upsertException({"$and": [{"a": "foo"}, {"a": "foo"}]}, {}); //not even with same value - upsertException({"a": {"$all": ["foo", "bar"]}}, {}); - upsertException({"$and": [{"a": {"$eq": "foo"}}, {"$or": [{"a": {"$all": ["bar"]}}]}]}, {}); - - // You can't have nested dotted fields - upsertException({"a": {"foo.bar": "baz"}}, {}); - - // You can't have dollar-prefixed fields above the first level (logical operators not counted) - upsertException({"a": {"a": {"$eq": "foo"}}}, {}); - upsertException({"a": {"a": {"$exists": true}}}, {}); - - // You can't mix operators with other fields - upsertException({"a": {"$eq": "bar", "b": "foo"}}, {}) - upsertException({"a": {"b": "foo", "$eq": "bar"}}, {}) - - var mongoIdForUpsert = new MongoID.ObjectID('44915733af80844fa1cef07a'); - upsert({_id: mongoIdForUpsert},{$setOnInsert: {a: 123}},{a: 123}) - - // Test for https://github.com/meteor/meteor/issues/7758 - upsert({n_id: mongoIdForUpsert, c_n: "bar"}, - {$set: { t_t_o: "foo"}}, - {n_id: mongoIdForUpsert, t_t_o: "foo", c_n: "bar"}); - - - exception({}, {$set: {_id: 'bad'}}); - - // $bit - // unimplemented - - // XXX test case sensitivity of modops - // XXX for each (most) modop, test that it performs a deep copy -}); - -// XXX test update() (selecting docs, multi, upsert..) - -Tinytest.add("minimongo - observe ordered", function (test) { - var operations = []; - var cbs = log_callbacks(operations); - var handle; - - var c = new LocalCollection(); - handle = c.find({}, {sort: {a: 1}}).observe(cbs); - test.isTrue(handle.collection === c); - - c.insert({_id: 'foo', a:1}); - test.equal(operations.shift(), ['added', {a:1}, 0, null]); - c.update({a:1}, {$set: {a: 2}}); - test.equal(operations.shift(), ['changed', {a:2}, 0, {a:1}]); - c.insert({a:10}); - test.equal(operations.shift(), ['added', {a:10}, 1, null]); - c.update({}, {$inc: {a: 1}}, {multi: true}); - test.equal(operations.shift(), ['changed', {a:3}, 0, {a:2}]); - test.equal(operations.shift(), ['changed', {a:11}, 1, {a:10}]); - c.update({a:11}, {a:1}); - test.equal(operations.shift(), ['changed', {a:1}, 1, {a:11}]); - test.equal(operations.shift(), ['moved', {a:1}, 1, 0, 'foo']); - c.remove({a:2}); - test.equal(operations.shift(), undefined); - c.remove({a:3}); - test.equal(operations.shift(), ['removed', 'foo', 1, {a:3}]); - - // test stop - handle.stop(); - var idA2 = Random.id(); - c.insert({_id: idA2, a:2}); - test.equal(operations.shift(), undefined); - - // test initial inserts (and backwards sort) - handle = c.find({}, {sort: {a: -1}}).observe(cbs); - test.equal(operations.shift(), ['added', {a:2}, 0, null]); - test.equal(operations.shift(), ['added', {a:1}, 1, null]); - handle.stop(); - - // test _suppress_initial - handle = c.find({}, {sort: {a: -1}}).observe(_.extend({ - _suppress_initial: true}, cbs)); - test.equal(operations.shift(), undefined); - c.insert({a:100}); - test.equal(operations.shift(), ['added', {a:100}, 0, idA2]); - handle.stop(); - - // test skip and limit. - c.remove({}); - handle = c.find({}, {sort: {a: 1}, skip: 1, limit: 2}).observe(cbs); - test.equal(operations.shift(), undefined); - c.insert({a:1}); - test.equal(operations.shift(), undefined); - c.insert({_id: 'foo', a:2}); - test.equal(operations.shift(), ['added', {a:2}, 0, null]); - c.insert({a:3}); - test.equal(operations.shift(), ['added', {a:3}, 1, null]); - c.insert({a:4}); - test.equal(operations.shift(), undefined); - c.update({a:1}, {a:0}); - test.equal(operations.shift(), undefined); - c.update({a:0}, {a:5}); - test.equal(operations.shift(), ['removed', 'foo', 0, {a:2}]); - test.equal(operations.shift(), ['added', {a:4}, 1, null]); - c.update({a:3}, {a:3.5}); - test.equal(operations.shift(), ['changed', {a:3.5}, 0, {a:3}]); - handle.stop(); - - // test observe limit with pre-existing docs - c.remove({}); - c.insert({a: 1}); - c.insert({_id: 'two', a: 2}); - c.insert({a: 3}); - handle = c.find({}, {sort: {a: 1}, limit: 2}).observe(cbs); - test.equal(operations.shift(), ['added', {a:1}, 0, null]); - test.equal(operations.shift(), ['added', {a:2}, 1, null]); - test.equal(operations.shift(), undefined); - c.remove({a: 2}); - test.equal(operations.shift(), ['removed', 'two', 1, {a:2}]); - test.equal(operations.shift(), ['added', {a:3}, 1, null]); - test.equal(operations.shift(), undefined); - handle.stop(); - - // test _no_indices - - c.remove({}); - handle = c.find({}, {sort: {a: 1}}).observe(_.extend(cbs, {_no_indices: true})); - c.insert({_id: 'foo', a:1}); - test.equal(operations.shift(), ['added', {a:1}, -1, null]); - c.update({a:1}, {$set: {a: 2}}); - test.equal(operations.shift(), ['changed', {a:2}, -1, {a:1}]); - c.insert({a:10}); - test.equal(operations.shift(), ['added', {a:10}, -1, null]); - c.update({}, {$inc: {a: 1}}, {multi: true}); - test.equal(operations.shift(), ['changed', {a:3}, -1, {a:2}]); - test.equal(operations.shift(), ['changed', {a:11}, -1, {a:10}]); - c.update({a:11}, {a:1}); - test.equal(operations.shift(), ['changed', {a:1}, -1, {a:11}]); - test.equal(operations.shift(), ['moved', {a:1}, -1, -1, 'foo']); - c.remove({a:2}); - test.equal(operations.shift(), undefined); - c.remove({a:3}); - test.equal(operations.shift(), ['removed', 'foo', -1, {a:3}]); - handle.stop(); -}); - -_.each([true, false], function (ordered) { - Tinytest.add("minimongo - observe ordered: " + ordered, function (test) { - var c = new LocalCollection(); - - var ev = ""; - var makecb = function (tag) { - var ret = {}; - _.each(["added", "changed", "removed"], function (fn) { - var fnName = ordered ? fn + "At" : fn; - ret[fnName] = function (doc) { - ev = (ev + fn.substr(0, 1) + tag + doc._id + "_"); - }; - }); - return ret; - }; - var expect = function (x) { - test.equal(ev, x); - ev = ""; - }; - - c.insert({_id: 1, name: "strawberry", tags: ["fruit", "red", "squishy"]}); - c.insert({_id: 2, name: "apple", tags: ["fruit", "red", "hard"]}); - c.insert({_id: 3, name: "rose", tags: ["flower", "red", "squishy"]}); - - // This should work equally well for ordered and unordered observations - // (because the callbacks don't look at indices and there's no 'moved' - // callback). - var handle = c.find({tags: "flower"}).observe(makecb('a')); - expect("aa3_"); - c.update({name: "rose"}, {$set: {tags: ["bloom", "red", "squishy"]}}); - expect("ra3_"); - c.update({name: "rose"}, {$set: {tags: ["flower", "red", "squishy"]}}); - expect("aa3_"); - c.update({name: "rose"}, {$set: {food: false}}); - expect("ca3_"); - c.remove({}); - expect("ra3_"); - c.insert({_id: 4, name: "daisy", tags: ["flower"]}); - expect("aa4_"); - handle.stop(); - // After calling stop, no more callbacks are called. - c.insert({_id: 5, name: "iris", tags: ["flower"]}); - expect(""); - - // Test that observing a lookup by ID works. - handle = c.find(4).observe(makecb('b')); - expect('ab4_'); - c.update(4, {$set: {eek: 5}}); - expect('cb4_'); - handle.stop(); - - // Test observe with reactive: false. - handle = c.find({tags: "flower"}, {reactive: false}).observe(makecb('c')); - expect('ac4_ac5_'); - // This insert shouldn't trigger a callback because it's not reactive. - c.insert({_id: 6, name: "river", tags: ["flower"]}); - expect(''); - handle.stop(); - }); -}); - - -Tinytest.add("minimongo - saveOriginals", function (test) { - // set up some data - var c = new LocalCollection(), - count; - c.insert({_id: 'foo', x: 'untouched'}); - c.insert({_id: 'bar', x: 'updateme'}); - c.insert({_id: 'baz', x: 'updateme'}); - c.insert({_id: 'quux', y: 'removeme'}); - c.insert({_id: 'whoa', y: 'removeme'}); - - // Save originals and make some changes. - c.saveOriginals(); - c.insert({_id: "hooray", z: 'insertme'}); - c.remove({y: 'removeme'}); - count = c.update({x: 'updateme'}, {$set: {z: 5}}, {multi: true}); - c.update('bar', {$set: {k: 7}}); // update same doc twice - - // Verify returned count is correct - test.equal(count, 2); - - // Verify the originals. - var originals = c.retrieveOriginals(); - var affected = ['bar', 'baz', 'quux', 'whoa', 'hooray']; - test.equal(originals.size(), _.size(affected)); - _.each(affected, function (id) { - test.isTrue(originals.has(id)); - }); - test.equal(originals.get('bar'), {_id: 'bar', x: 'updateme'}); - test.equal(originals.get('baz'), {_id: 'baz', x: 'updateme'}); - test.equal(originals.get('quux'), {_id: 'quux', y: 'removeme'}); - test.equal(originals.get('whoa'), {_id: 'whoa', y: 'removeme'}); - test.equal(originals.get('hooray'), undefined); - - // Verify that changes actually occured. - test.equal(c.find().count(), 4); - test.equal(c.findOne('foo'), {_id: 'foo', x: 'untouched'}); - test.equal(c.findOne('bar'), {_id: 'bar', x: 'updateme', z: 5, k: 7}); - test.equal(c.findOne('baz'), {_id: 'baz', x: 'updateme', z: 5}); - test.equal(c.findOne('hooray'), {_id: 'hooray', z: 'insertme'}); - - // The next call doesn't get the same originals again. - c.saveOriginals(); - originals = c.retrieveOriginals(); - test.isTrue(originals); - test.isTrue(originals.empty()); - - // Insert and remove a document during the period. - c.saveOriginals(); - c.insert({_id: 'temp', q: 8}); - c.remove('temp'); - originals = c.retrieveOriginals(); - test.equal(originals.size(), 1); - test.isTrue(originals.has('temp')); - test.equal(originals.get('temp'), undefined); -}); - -Tinytest.add("minimongo - saveOriginals errors", function (test) { - var c = new LocalCollection(); - // Can't call retrieve before save. - test.throws(function () { c.retrieveOriginals(); }); - c.saveOriginals(); - // Can't call save twice. - test.throws(function () { c.saveOriginals(); }); -}); - -Tinytest.add("minimongo - objectid transformation", function (test) { - var testId = function (item) { - test.equal(item, MongoID.idParse(MongoID.idStringify(item))); - }; - var randomOid = new MongoID.ObjectID(); - testId(randomOid); - testId("FOO"); - testId("ffffffffffff"); - testId("0987654321abcdef09876543"); - testId(new MongoID.ObjectID()); - testId("--a string"); - - test.equal("ffffffffffff", MongoID.idParse(MongoID.idStringify("ffffffffffff"))); -}); - - -Tinytest.add("minimongo - objectid", function (test) { - var randomOid = new MongoID.ObjectID(); - var anotherRandomOid = new MongoID.ObjectID(); - test.notEqual(randomOid, anotherRandomOid); - test.throws(function() { new MongoID.ObjectID("qqqqqqqqqqqqqqqqqqqqqqqq");}); - test.throws(function() { new MongoID.ObjectID("ABCDEF"); }); - test.equal(randomOid, new MongoID.ObjectID(randomOid.valueOf())); -}); + }, /transform must return object/); -Tinytest.add("minimongo - pause", function (test) { - var operations = []; - var cbs = log_callbacks(operations); - - var c = new LocalCollection(); - var h = c.find({}).observe(cbs); - - // remove and add cancel out. - c.insert({_id: 1, a: 1}); - test.equal(operations.shift(), ['added', {a:1}, 0, null]); - - c.pauseObservers(); - - c.remove({_id: 1}); - test.length(operations, 0); - c.insert({_id: 1, a: 1}); - test.length(operations, 0); - - c.resumeObservers(); - test.length(operations, 0); - - - // two modifications become one - c.pauseObservers(); - - c.update({_id: 1}, {a: 2}); - c.update({_id: 1}, {a: 3}); - - c.resumeObservers(); - test.equal(operations.shift(), ['changed', {a:3}, 0, {a:1}]); - test.length(operations, 0); - - // test special case for remove({}) - c.pauseObservers(); - test.equal(c.remove({}), 1); - test.length(operations, 0); - c.resumeObservers(); - test.equal(operations.shift(), ['removed', 1, 0, {a:3}]); - test.length(operations, 0); - - h.stop(); -}); - -Tinytest.add("minimongo - ids matched by selector", function (test) { - var check = function (selector, ids) { - var idsFromSelector = LocalCollection._idsMatchedBySelector(selector); - // XXX normalize order, in a way that also works for ObjectIDs? - test.equal(idsFromSelector, ids); - }; - check("foo", ["foo"]); - check({_id: "foo"}, ["foo"]); - var oid1 = new MongoID.ObjectID(); - check(oid1, [oid1]); - check({_id: oid1}, [oid1]); - check({_id: "foo", x: 42}, ["foo"]); - check({}, null); - check({_id: {$in: ["foo", oid1]}}, ["foo", oid1]); - check({_id: {$ne: "foo"}}, null); - // not actually valid, but works for now... - check({$and: ["foo"]}, ["foo"]); - check({$and: [{x: 42}, {_id: oid1}]}, [oid1]); - check({$and: [{x: 42}, {_id: {$in: [oid1]}}]}, [oid1]); -}); + // transform functions may not change _ids + const wrapped = wrap(doc => { doc._id = 'x'; return doc; }); + test.throws(() => { + wrapped({_id: 'y'}); + }, /can't have different _id/); -Tinytest.add("minimongo - reactive stop", function (test) { - var coll = new LocalCollection(); - coll.insert({_id: 'A'}); - coll.insert({_id: 'B'}); - coll.insert({_id: 'C'}); + // transform functions may remove _ids + test.equal({_id: 'a', x: 2}, + wrap(d => {delete d._id; return d;})({_id: 'a', x: 2})); - var addBefore = function (str, newChar, before) { - var idx = str.indexOf(before); - if (idx === -1) - return str + newChar; - return str.slice(0, idx) + newChar + str.slice(idx); + // test that wrapped transform functions are nonreactive + const unwrapped = doc => { + test.isFalse(Tracker.active); + return doc; }; - - var x, y; - var sortOrder = ReactiveVar(1); - - var c = Tracker.autorun(function () { - var q = coll.find({}, {sort: {_id: sortOrder.get()}}); - x = ""; - q.observe({ addedAt: function (doc, atIndex, before) { - x = addBefore(x, doc._id, before); - }}); - y = ""; - q.observeChanges({ addedBefore: function (id, fields, before) { - y = addBefore(y, id, before); - }}); - }); - - test.equal(x, "ABC"); - test.equal(y, "ABC"); - - sortOrder.set(-1); - test.equal(x, "ABC"); - test.equal(y, "ABC"); - Tracker.flush(); - test.equal(x, "CBA"); - test.equal(y, "CBA"); - - coll.insert({_id: 'D'}); - coll.insert({_id: 'E'}); - test.equal(x, "EDCBA"); - test.equal(y, "EDCBA"); - - c.stop(); - // stopping kills the observes immediately - coll.insert({_id: 'F'}); - test.equal(x, "EDCBA"); - test.equal(y, "EDCBA"); -}); - -Tinytest.add("minimongo - immediate invalidate", function (test) { - var coll = new LocalCollection(); - coll.insert({_id: 'A'}); - - // This has two separate findOnes. findOne() uses skip/limit, which means - // that its response to an update() call involves a recompute. We used to have - // a bug where we would first calculate all the calls that need to be - // recomputed, then recompute them one by one, without checking to see if the - // callbacks from recomputing one query stopped the second query, which - // crashed. - var c = Tracker.autorun(function () { - coll.findOne('A'); - coll.findOne('A'); - }); - - coll.update('A', {$set: {x: 42}}); - - c.stop(); -}); - - -Tinytest.add("minimongo - count on cursor with limit", function(test){ - var coll = new LocalCollection(), count; - - coll.insert({_id: 'A'}); - coll.insert({_id: 'B'}); - coll.insert({_id: 'C'}); - coll.insert({_id: 'D'}); - - var c = Tracker.autorun(function (c) { - var cursor = coll.find({_id: {$exists: true}}, {sort: {_id: 1}, limit: 3}); - count = cursor.count(); - }); - - test.equal(count, 3); - - coll.remove('A'); // still 3 in the collection - Tracker.flush(); - test.equal(count, 3); - - coll.remove('B'); // expect count now 2 - Tracker.flush(); - test.equal(count, 2); - - - coll.insert({_id: 'A'}); // now 3 again - Tracker.flush(); - test.equal(count, 3); - - coll.insert({_id: 'B'}); // now 4 entries, but count should be 3 still - Tracker.flush(); - test.equal(count, 3); - - c.stop(); -}); - -Tinytest.add("minimongo - reactive count with cached cursor", function (test) { - var coll = new LocalCollection; - var cursor = coll.find({}); - var firstAutorunCount, secondAutorunCount; - Tracker.autorun(function(){ - firstAutorunCount = cursor.count(); - }); - Tracker.autorun(function(){ - secondAutorunCount = coll.find({}).count(); - }); - test.equal(firstAutorunCount, 0); - test.equal(secondAutorunCount, 0); - coll.insert({i: 1}); - coll.insert({i: 2}); - coll.insert({i: 3}); - Tracker.flush(); - test.equal(firstAutorunCount, 3); - test.equal(secondAutorunCount, 3); -}); - -Tinytest.add("minimongo - $near operator tests", function (test) { - var coll = new LocalCollection(); - coll.insert({ rest: { loc: [2, 3] } }); - coll.insert({ rest: { loc: [-3, 3] } }); - coll.insert({ rest: { loc: [5, 5] } }); - - test.equal(coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 30 } }).count(), 3); - test.equal(coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 4 } }).count(), 1); - var points = coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 6 } }).fetch(); - _.each(points, function (point, i, points) { - test.isTrue(!i || distance([0, 0], point.rest.loc) >= distance([0, 0], points[i - 1].rest.loc)); - }); - - function distance(a, b) { - var x = a[0] - b[0]; - var y = a[1] - b[1]; - return Math.sqrt(x * x + y * y); - } - - // GeoJSON tests - coll = new LocalCollection(); - var data = [{ "category" : "BURGLARY", "descript" : "BURGLARY OF STORE, FORCIBLE ENTRY", "address" : "100 Block of 10TH ST", "location" : { "type" : "Point", "coordinates" : [ -122.415449723856, 37.7749518087273 ] } }, - { "category" : "WEAPON LAWS", "descript" : "POSS OF PROHIBITED WEAPON", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415386041221, 37.7747879744156 ] } }, - { "category" : "LARCENY/THEFT", "descript" : "GRAND THEFT OF PROPERTY", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.41538270191, 37.774683628213 ] } }, - { "category" : "LARCENY/THEFT", "descript" : "PETTY THEFT FROM LOCKED AUTO", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415396041221, 37.7747879744156 ] } }, - { "category" : "OTHER OFFENSES", "descript" : "POSSESSION OF BURGLARY TOOLS", "address" : "900 Block of MINNA ST", "location" : { "type" : "Point", "coordinates" : [ -122.415386041221, 37.7747879734156 ] } } - ]; - - _.each(data, function (x, i) { coll.insert(_.extend(x, { x: i })); }); - - var close15 = coll.find({ location: { $near: { - $geometry: { type: "Point", - coordinates: [-122.4154282, 37.7746115] }, - $maxDistance: 15 } } }).fetch(); - test.length(close15, 1); - test.equal(close15[0].descript, "GRAND THEFT OF PROPERTY"); - - var close20 = coll.find({ location: { $near: { - $geometry: { type: "Point", - coordinates: [-122.4154282, 37.7746115] }, - $maxDistance: 20 } } }).fetch(); - test.length(close20, 4); - test.equal(close20[0].descript, "GRAND THEFT OF PROPERTY"); - test.equal(close20[1].descript, "PETTY THEFT FROM LOCKED AUTO"); - test.equal(close20[2].descript, "POSSESSION OF BURGLARY TOOLS"); - test.equal(close20[3].descript, "POSS OF PROHIBITED WEAPON"); - - // Any combinations of $near with $or/$and/$nor/$not should throw an error - test.throws(function () { - coll.find({ location: { - $not: { - $near: { - $geometry: { - type: "Point", - coordinates: [-122.4154282, 37.7746115] - }, $maxDistance: 20 } } } }); - }); - test.throws(function () { - coll.find({ - $and: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 20 }}}, - { x: 0 }] - }); - }); - test.throws(function () { - coll.find({ - $or: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 20 }}}, - { x: 0 }] - }); - }); - test.throws(function () { - coll.find({ - $nor: [ { location: { $near: { $geometry: { type: "Point", coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 1 }}}, - { x: 0 }] - }); - }); - test.throws(function () { - coll.find({ - $and: [{ - $and: [{ - location: { - $near: { - $geometry: { - type: "Point", - coordinates: [-122.4154282, 37.7746115] - }, - $maxDistance: 1 - } - } - }] - }] - }); + const handle = Tracker.autorun(() => { + test.isTrue(Tracker.active); + wrap(unwrapped)({_id: 'xxx'}); }); - - // array tests - coll = new LocalCollection(); - coll.insert({ - _id: "x", - k: 9, - a: [ - {b: [ - [100, 100], - [1, 1]]}, - {b: [150, 150]}]}); - coll.insert({ - _id: "y", - k: 9, - a: {b: [5, 5]}}); - var testNear = function (near, md, expected) { - test.equal( - _.pluck( - coll.find({'a.b': {$near: near, $maxDistance: md}}).fetch(), '_id'), - expected); - }; - testNear([149, 149], 4, ['x']); - testNear([149, 149], 1000, ['x', 'y']); - // It's important that we figure out that 'x' is closer than 'y' to [2,2] even - // though the first within-1000 point in 'x' (ie, [100,100]) is farther than - // 'y'. - testNear([2, 2], 1000, ['x', 'y']); - - // issue #3599 - // Ensure that distance is not used as a tie-breaker for sort. - test.equal( - _.pluck(coll.find({'a.b': {$near: [1, 1]}}, {sort: {k: 1}}).fetch(), '_id'), - ['x', 'y']); - test.equal( - _.pluck(coll.find({'a.b': {$near: [5, 5]}}, {sort: {k: 1}}).fetch(), '_id'), - ['x', 'y']); - - var operations = []; - var cbs = log_callbacks(operations); - var handle = coll.find({'a.b': {$near: [7,7]}}).observe(cbs); - - test.length(operations, 2); - test.equal(operations.shift(), ['added', {k:9, a:{b:[5,5]}}, 0, null]); - test.equal(operations.shift(), - ['added', {k: 9, a:[{b:[[100,100],[1,1]]},{b:[150,150]}]}, - 1, null]); - // This needs to be inserted in the MIDDLE of the two existing ones. - coll.insert({a: {b: [3,3]}}); - test.length(operations, 1); - test.equal(operations.shift(), ['added', {a: {b: [3, 3]}}, 1, 'x']); - handle.stop(); }); - -// issue #2077 -Tinytest.add("minimongo - $near and $geometry for legacy coordinates", function(test){ - var coll = new LocalCollection(); - - coll.insert({ - loc: { - x: 1, - y: 1 - } - }); - coll.insert({ - loc: [-1,-1] - }); - coll.insert({ - loc: [40,-10] - }); - coll.insert({ - loc: { - x: -10, - y: 40 - } - }); - - test.equal(coll.find({ 'loc': { $near: [0, 0], $maxDistance: 4 } }).count(), 2); - test.equal(coll.find({ 'loc': { $near: {$geometry: {type: "Point", coordinates: [0, 0]}}} }).count(), 4); - test.equal(coll.find({ 'loc': { $near: {$geometry: {type: "Point", coordinates: [0, 0]}, $maxDistance:200000}}}).count(), 2); - -}); - -// Regression test for #4377. Previously, "replace" updates didn't clone the -// argument. -Tinytest.add("minimongo - update should clone", function (test) { - var x = []; - var coll = new LocalCollection; - var id = coll.insert({}); - coll.update(id, {x: x}); - x.push(1); - test.equal(coll.findOne(id), {_id: id, x: []}); -}); - -// See #2275. -Tinytest.add("minimongo - fetch in observe", function (test) { - var coll = new LocalCollection; - var callbackInvoked = false; - var observe = coll.find().observeChanges({ - added: function (id, fields) { - callbackInvoked = true; - test.equal(fields, {foo: 1}); - var doc = coll.findOne({foo: 1}); - test.isTrue(doc); - test.equal(doc.foo, 1); - } - }); - test.isFalse(callbackInvoked); - var computation = Tracker.autorun(function (computation) { - if (computation.firstRun) { - coll.insert({foo: 1}); - } - }); - test.isTrue(callbackInvoked); - observe.stop(); - computation.stop(); -}); - -// See #2254 -Tinytest.add("minimongo - fine-grained reactivity of observe with fields projection", function (test) { - var X = new LocalCollection; - var id = "asdf"; - X.insert({_id: id, foo: {bar: 123}}); - - var callbackInvoked = false; - var obs = X.find(id, {fields: {'foo.bar': 1}}).observeChanges({ - changed: function (id, fields) { - callbackInvoked = true; - } - }); - - test.isFalse(callbackInvoked); - X.update(id, {$set: {'foo.baz': 456}}); - test.isFalse(callbackInvoked); - - obs.stop(); -}); -Tinytest.add("minimongo - fine-grained reactivity of query with fields projection", function (test) { - var X = new LocalCollection; - var id = "asdf"; - X.insert({_id: id, foo: {bar: 123}}); - - var callbackInvoked = false; - var computation = Tracker.autorun(function () { - callbackInvoked = true; - return X.findOne(id, { fields: { 'foo.bar': 1 } }); - }); - test.isTrue(callbackInvoked); - callbackInvoked = false; - X.update(id, {$set: {'foo.baz': 456}}); - test.isFalse(callbackInvoked); - X.update(id, {$set: {'foo.bar': 124}}); - Tracker.flush(); - test.isTrue(callbackInvoked); - - computation.stop(); -}); - -// Tests that the logic in `LocalCollection.prototype.update` -// correctly deals with count() on a cursor with skip or limit (since -// then the result set is an IdMap, not an array) -Tinytest.add("minimongo - reactive skip/limit count while updating", function(test) { - var X = new LocalCollection; - var count = -1; - - var c = Tracker.autorun(function() { - count = X.find({}, {skip: 1, limit: 1}).count(); - }); - - test.equal(count, 0); - - X.insert({}); - Tracker.flush({_throwFirstError: true}); - test.equal(count, 0); - - X.insert({}); - Tracker.flush({_throwFirstError: true}); - test.equal(count, 1); - - X.update({}, {$set: {foo: 1}}); - Tracker.flush({_throwFirstError: true}); - test.equal(count, 1); - - // Make sure a second update also works - X.update({}, {$set: {foo: 2}}); - Tracker.flush({_throwFirstError: true}); - test.equal(count, 1); - - c.stop(); -}); - -// Makes sure inserts cannot be performed using field names that have -// Mongo restricted characters in them ('.', '$', '\0'): -// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names -Tinytest.add("minimongo - cannot insert using invalid field names", function (test) { - const collection = new LocalCollection(); - - // Quick test to make sure non-dot field inserts are working - collection.insert({ a: 'b' }); - - // Quick test to make sure field values with dots are allowed - collection.insert({ a: 'b.c' }); - - // Verify top level dot-field inserts are prohibited - ['a.b', '.b', 'a.', 'a.b.c'].forEach((field) => { - test.throws(function () { - collection.insert({ [field]: 'c' }); - }, `Key ${field} must not contain '.'`); - }); - - // Verify nested dot-field inserts are prohibited - test.throws(function () { - collection.insert({ a: { b: { 'c.d': 'e' } } }); - }, "Key c.d must not contain '.'"); - - // Verify field names starting with $ are prohibited - test.throws(function () { - collection.insert({ '$a': 'b' }); - }, "Key $a must not start with '$'"); - - // Verify nested field names starting with $ are prohibited - test.throws(function () { - collection.insert({ a: { b: { '$c': 'd' } } }); - }, "Key $c must not start with '$'"); - - // Verify top level fields with null characters are prohibited - ['\0a', 'a\0', 'a\0b', '\u0000a', 'a\u0000', 'a\u0000b'].forEach((field) => { - test.throws(function () { - collection.insert({ [field]: 'c' }); - }, `Key ${field} must not contain null bytes`); - }); - - // Verify nested field names with null characters are prohibited - test.throws(function () { - collection.insert({ a: { b: { '\0c': 'd' } } }); - }, 'Key \0c must not contain null bytes'); -}); - -// Makes sure $set's cannot be performed using null bytes -// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names -Tinytest.add("minimongo - cannot $set with null bytes", function (test) { - const collection = new LocalCollection(); - - // Quick test to make sure non-null byte $set's are working - const id = collection.insert({ a: 'b', 'c': 'd' }); - collection.update({ _id: id }, { $set: { e: 'f' } }); - - // Verify $set's with null bytes throw an exception - test.throws(() => { - collection.update({ _id: id }, { $set: { '\0a': 'b' } }); - }, 'Key \0a must not contain null bytes'); -}); - -// Makes sure $rename's cannot be performed using null bytes -// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names -Tinytest.add("minimongo - cannot $rename with null bytes", function (test) { - const collection = new LocalCollection(); - - // Quick test to make sure non-null byte $rename's are working - let id = collection.insert({ a: 'b', c: 'd' }); - collection.update({ _id: id }, { $rename: { a: 'a1', c: 'c1' } }); - - // Verify $rename's with null bytes throw an exception - collection.remove({}); - id = collection.insert({ a: 'b', c: 'd' }); - test.throws(() => { - collection.update({ _id: id }, { $rename: { a: '\0a', c: 'c\0' } }); - }, "The 'to' field for $rename cannot contain an embedded null byte"); -}); diff --git a/packages/minimongo/minimongo_tests_client.js b/packages/minimongo/minimongo_tests_client.js new file mode 100644 index 00000000000..430b4c084e1 --- /dev/null +++ b/packages/minimongo/minimongo_tests_client.js @@ -0,0 +1,3810 @@ +import {hasOwn} from './common'; + +// Hack to make LocalCollection generate ObjectIDs by default. +LocalCollection._useOID = true; + +// assert that f is a strcmp-style comparison function that puts +// 'values' in the provided order + +const assert_ordering = (test, f, values) => { + for (let i = 0; i < values.length; i++) { + let x = f(values[i], values[i]); + if (x !== 0) { + // XXX super janky + test.fail({type: 'minimongo-ordering', + message: "value doesn't order as equal to itself", + value: JSON.stringify(values[i]), + should_be_zero_but_got: JSON.stringify(x)}); + } + if (i + 1 < values.length) { + const less = values[i]; + const more = values[i + 1]; + x = f(less, more); + if (!(x < 0)) { + // XXX super janky + test.fail({type: 'minimongo-ordering', + message: 'ordering test failed', + first: JSON.stringify(less), + second: JSON.stringify(more), + should_be_negative_but_got: JSON.stringify(x)}); + } + x = f(more, less); + if (!(x > 0)) { + // XXX super janky + test.fail({type: 'minimongo-ordering', + message: 'ordering test failed', + first: JSON.stringify(less), + second: JSON.stringify(more), + should_be_positive_but_got: JSON.stringify(x)}); + } + } + } +}; + +const log_callbacks = operations => ({ + addedAt(obj, idx, before) { + delete obj._id; + operations.push(EJSON.clone(['added', obj, idx, before])); + }, + + changedAt(obj, old_obj, at) { + delete obj._id; + delete old_obj._id; + operations.push(EJSON.clone(['changed', obj, at, old_obj])); + }, + + movedTo(obj, old_at, new_at, before) { + delete obj._id; + operations.push(EJSON.clone(['moved', obj, old_at, new_at, before])); + }, + + removedAt(old_obj, at) { + const id = old_obj._id; + delete old_obj._id; + operations.push(EJSON.clone(['removed', id, at, old_obj])); + }, +}); + +// XXX test shared structure in all MM entrypoints +Tinytest.add('minimongo - basics', test => { + const c = new LocalCollection(); + let fluffyKitten_id; + let count; + + fluffyKitten_id = c.insert({type: 'kitten', name: 'fluffy'}); + c.insert({type: 'kitten', name: 'snookums'}); + c.insert({type: 'cryptographer', name: 'alice'}); + c.insert({type: 'cryptographer', name: 'bob'}); + c.insert({type: 'cryptographer', name: 'cara'}); + test.equal(c.find().count(), 5); + test.equal(c.find({type: 'kitten'}).count(), 2); + test.equal(c.find({type: 'cryptographer'}).count(), 3); + test.length(c.find({type: 'kitten'}).fetch(), 2); + test.length(c.find({type: 'cryptographer'}).fetch(), 3); + test.equal(fluffyKitten_id, c.findOne({type: 'kitten', name: 'fluffy'})._id); + + c.remove({name: 'cara'}); + test.equal(c.find().count(), 4); + test.equal(c.find({type: 'kitten'}).count(), 2); + test.equal(c.find({type: 'cryptographer'}).count(), 2); + test.length(c.find({type: 'kitten'}).fetch(), 2); + test.length(c.find({type: 'cryptographer'}).fetch(), 2); + + count = c.update({name: 'snookums'}, {$set: {type: 'cryptographer'}}); + test.equal(count, 1); + test.equal(c.find().count(), 4); + test.equal(c.find({type: 'kitten'}).count(), 1); + test.equal(c.find({type: 'cryptographer'}).count(), 3); + test.length(c.find({type: 'kitten'}).fetch(), 1); + test.length(c.find({type: 'cryptographer'}).fetch(), 3); + + c.remove(null); + c.remove(false); + c.remove(undefined); + test.equal(c.find().count(), 4); + + c.remove({_id: null}); + c.remove({_id: false}); + c.remove({_id: undefined}); + count = c.remove(); + test.equal(count, 0); + test.equal(c.find().count(), 4); + + count = c.remove({}); + test.equal(count, 4); + test.equal(c.find().count(), 0); + + c.insert({_id: 1, name: 'strawberry', tags: ['fruit', 'red', 'squishy']}); + c.insert({_id: 2, name: 'apple', tags: ['fruit', 'red', 'hard']}); + c.insert({_id: 3, name: 'rose', tags: ['flower', 'red', 'squishy']}); + + test.equal(c.find({tags: 'flower'}).count(), 1); + test.equal(c.find({tags: 'fruit'}).count(), 2); + test.equal(c.find({tags: 'red'}).count(), 3); + test.length(c.find({tags: 'flower'}).fetch(), 1); + test.length(c.find({tags: 'fruit'}).fetch(), 2); + test.length(c.find({tags: 'red'}).fetch(), 3); + + test.equal(c.findOne(1).name, 'strawberry'); + test.equal(c.findOne(2).name, 'apple'); + test.equal(c.findOne(3).name, 'rose'); + test.equal(c.findOne(4), undefined); + test.equal(c.findOne('abc'), undefined); + test.equal(c.findOne(undefined), undefined); + + test.equal(c.find(1).count(), 1); + test.equal(c.find(4).count(), 0); + test.equal(c.find('abc').count(), 0); + test.equal(c.find(undefined).count(), 0); + test.equal(c.find().count(), 3); + test.equal(c.find(1, {skip: 1}).count(), 0); + test.equal(c.find({_id: 1}, {skip: 1}).count(), 0); + test.equal(c.find({}, {skip: 1}).count(), 2); + test.equal(c.find({}, {skip: 2}).count(), 1); + test.equal(c.find({}, {limit: 2}).count(), 2); + test.equal(c.find({}, {limit: 1}).count(), 1); + test.equal(c.find({}, {skip: 1, limit: 1}).count(), 1); + test.equal(c.find({tags: 'fruit'}, {skip: 1}).count(), 1); + test.equal(c.find({tags: 'fruit'}, {limit: 1}).count(), 1); + test.equal(c.find({tags: 'fruit'}, {skip: 1, limit: 1}).count(), 1); + test.equal(c.find(1, {sort: ['_id', 'desc'], skip: 1}).count(), 0); + test.equal(c.find({_id: 1}, {sort: ['_id', 'desc'], skip: 1}).count(), 0); + test.equal(c.find({}, {sort: ['_id', 'desc'], skip: 1}).count(), 2); + test.equal(c.find({}, {sort: ['_id', 'desc'], skip: 2}).count(), 1); + test.equal(c.find({}, {sort: ['_id', 'desc'], limit: 2}).count(), 2); + test.equal(c.find({}, {sort: ['_id', 'desc'], limit: 1}).count(), 1); + test.equal(c.find({}, {sort: ['_id', 'desc'], skip: 1, limit: 1}).count(), 1); + test.equal(c.find({tags: 'fruit'}, {sort: ['_id', 'desc'], skip: 1}).count(), 1); + test.equal(c.find({tags: 'fruit'}, {sort: ['_id', 'desc'], limit: 1}).count(), 1); + test.equal(c.find({tags: 'fruit'}, {sort: ['_id', 'desc'], skip: 1, limit: 1}).count(), 1); + + // Regression test for #455. + c.insert({foo: {bar: 'baz'}}); + test.equal(c.find({foo: {bam: 'baz'}}).count(), 0); + test.equal(c.find({foo: {bar: 'baz'}}).count(), 1); + + // Regression test for #5301 + c.remove({}); + c.insert({a: 'a', b: 'b'}); + const noop = () => null; + test.equal(c.find({a: noop}).count(), 1); + test.equal(c.find({a: 'a', b: noop}).count(), 1); + test.equal(c.find({c: noop}).count(), 1); + test.equal(c.find({a: noop, c: 'c'}).count(), 0); +}); + +Tinytest.add('minimongo - error - no options', test => { + try { + throw MinimongoError('Not fun to have errors'); + } catch (e) { + test.equal(e.message, 'Not fun to have errors'); + } +}); + +Tinytest.add('minimongo - error - with field', test => { + try { + throw MinimongoError('Cats are no fun', { field: 'mice' }); + } catch (e) { + test.equal(e.message, "Cats are no fun for field 'mice'"); + } +}); + +Tinytest.add('minimongo - cursors', test => { + const c = new LocalCollection(); + let res; + + for (let i = 0; i < 20; i++) {c.insert({i});} + + const q = c.find(); + test.equal(q.count(), 20); + + // fetch + res = q.fetch(); + test.length(res, 20); + for (let i = 0; i < 20; i++) { + test.equal(res[i].i, i); + } + // call it again, it still works + test.length(q.fetch(), 20); + + // forEach + let count = 0; + const context = {}; + q.forEach(function(obj, i, cursor) { + test.equal(obj.i, count++); + test.equal(obj.i, i); + test.isTrue(context === this); + test.isTrue(cursor === q); + }, context); + test.equal(count, 20); + // call it again, it still works + test.length(q.fetch(), 20); + + // map + res = q.map(function(obj, i, cursor) { + test.equal(obj.i, i); + test.isTrue(context === this); + test.isTrue(cursor === q); + return obj.i * 2; + }, context); + test.length(res, 20); + for (let i = 0; i < 20; i++) {test.equal(res[i], i * 2);} + // call it again, it still works + test.length(q.fetch(), 20); + + // findOne (and no rewind first) + test.equal(c.findOne({i: 0}).i, 0); + test.equal(c.findOne({i: 1}).i, 1); + const id = c.findOne({i: 2})._id; + test.equal(c.findOne(id).i, 2); +}); + +Tinytest.add('minimongo - transform', test => { + const c = new LocalCollection; + c.insert({}); + // transform functions must return objects + const invalidTransform = doc => doc._id; + test.throws(() => { + c.findOne({}, {transform: invalidTransform}); + }); + + // transformed documents get _id field transplanted if not present + const transformWithoutId = doc => { + const docWithoutId = Object.assign({}, doc); + delete docWithoutId._id; + return docWithoutId; + }; + test.equal(c.findOne({}, {transform: transformWithoutId})._id, + c.findOne()._id); +}); + +Tinytest.add('minimongo - misc', test => { + // deepcopy + let a = {a: [1, 2, 3], b: 'x', c: true, d: {x: 12, y: [12]}, + f: null, g: new Date()}; + let b = EJSON.clone(a); + test.equal(a, b); + test.isTrue(LocalCollection._f._equal(a, b)); + a.a.push(4); + test.length(b.a, 3); + a.c = false; + test.isTrue(b.c); + b.d.z = 15; + a.d.z = 14; + test.equal(b.d.z, 15); + a.d.y.push(88); + test.length(b.d.y, 1); + test.equal(a.g, b.g); + b.g.setDate(b.g.getDate() + 1); + test.notEqual(a.g, b.g); + + a = {x() {}}; + b = EJSON.clone(a); + a.x.a = 14; + test.equal(b.x.a, 14); // just to document current behavior +}); + +Tinytest.add('minimongo - lookup', test => { + const lookupA = MinimongoTest.makeLookupFunction('a'); + test.equal(lookupA({}), [{value: undefined}]); + test.equal(lookupA({a: 1}), [{value: 1}]); + test.equal(lookupA({a: [1]}), [{value: [1]}]); + + const lookupAX = MinimongoTest.makeLookupFunction('a.x'); + test.equal(lookupAX({a: {x: 1}}), [{value: 1}]); + test.equal(lookupAX({a: {x: [1]}}), [{value: [1]}]); + test.equal(lookupAX({a: 5}), [{value: undefined}]); + test.equal(lookupAX({a: [{x: 1}, {x: [2]}, {y: 3}]}), + [{value: 1, arrayIndices: [0]}, + {value: [2], arrayIndices: [1]}, + {value: undefined, arrayIndices: [2]}]); + + const lookupA0X = MinimongoTest.makeLookupFunction('a.0.x'); + test.equal(lookupA0X({a: [{x: 1}]}), [ + // From interpreting '0' as "0th array element". + {value: 1, arrayIndices: [0, 'x']}, + // From interpreting '0' as "after branching in the array, look in the + // object {x:1} for a field named 0". + {value: undefined, arrayIndices: [0]}]); + test.equal(lookupA0X({a: [{x: [1]}]}), [ + {value: [1], arrayIndices: [0, 'x']}, + {value: undefined, arrayIndices: [0]}]); + test.equal(lookupA0X({a: 5}), [{value: undefined}]); + test.equal(lookupA0X({a: [{x: 1}, {x: [2]}, {y: 3}]}), [ + // From interpreting '0' as "0th array element". + {value: 1, arrayIndices: [0, 'x']}, + // From interpreting '0' as "after branching in the array, look in the + // object {x:1} for a field named 0". + {value: undefined, arrayIndices: [0]}, + {value: undefined, arrayIndices: [1]}, + {value: undefined, arrayIndices: [2]}, + ]); + + test.equal( + MinimongoTest.makeLookupFunction('w.x.0.z')({ + w: [{x: [{z: 5}]}]}), [ + // From interpreting '0' as "0th array element". + {value: 5, arrayIndices: [0, 0, 'x']}, + // From interpreting '0' as "after branching in the array, look in the + // object {z:5} for a field named "0". + {value: undefined, arrayIndices: [0, 0]}, + ]); +}); + +Tinytest.add('minimongo - selector_compiler', test => { + const matches = (shouldMatch, selector, doc) => { + const doesMatch = new Minimongo.Matcher(selector).documentMatches(doc).result; + if (doesMatch != shouldMatch) { + // XXX super janky + test.fail({message: `minimongo match failure: document ${shouldMatch ? "should match, but doesn't" : + "shouldn't match, but does"}`, + selector: JSON.stringify(selector), + document: JSON.stringify(doc), + }); + } + }; + + const match = matches.bind(null, true); + const nomatch = matches.bind(null, false); + + // XXX blog post about what I learned while writing these tests (weird + // mongo edge cases) + + // empty selectors + match({}, {}); + match({}, {a: 12}); + + // scalars + match(1, {_id: 1, a: 'foo'}); + nomatch(1, {_id: 2, a: 'foo'}); + match('a', {_id: 'a', a: 'foo'}); + nomatch('a', {_id: 'b', a: 'foo'}); + + // safety + nomatch(undefined, {}); + nomatch(undefined, {_id: 'foo'}); + nomatch(false, {_id: 'foo'}); + nomatch(null, {_id: 'foo'}); + nomatch({_id: undefined}, {_id: 'foo'}); + nomatch({_id: false}, {_id: 'foo'}); + nomatch({_id: null}, {_id: 'foo'}); + + // matching one or more keys + nomatch({a: 12}, {}); + match({a: 12}, {a: 12}); + match({a: 12}, {a: 12, b: 13}); + match({a: 12, b: 13}, {a: 12, b: 13}); + match({a: 12, b: 13}, {a: 12, b: 13, c: 14}); + nomatch({a: 12, b: 13, c: 14}, {a: 12, b: 13}); + nomatch({a: 12, b: 13}, {b: 13, c: 14}); + + match({a: 12}, {a: [12]}); + match({a: 12}, {a: [11, 12, 13]}); + nomatch({a: 12}, {a: [11, 13]}); + match({a: 12, b: 13}, {a: [11, 12, 13], b: [13, 14, 15]}); + nomatch({a: 12, b: 13}, {a: [11, 12, 13], b: [14, 15]}); + + // dates + const date1 = new Date; + const date2 = new Date(date1.getTime() + 1000); + match({a: date1}, {a: date1}); + nomatch({a: date1}, {a: date2}); + + + // arrays + match({a: [1, 2]}, {a: [1, 2]}); + match({a: [1, 2]}, {a: [[1, 2]]}); + match({a: [1, 2]}, {a: [[3, 4], [1, 2]]}); + nomatch({a: [1, 2]}, {a: [3, 4]}); + nomatch({a: [1, 2]}, {a: [[[1, 2]]]}); + + // literal documents + match({a: {b: 12}}, {a: {b: 12}}); + nomatch({a: {b: 12, c: 13}}, {a: {b: 12}}); + nomatch({a: {b: 12}}, {a: {b: 12, c: 13}}); + match({a: {b: 12, c: 13}}, {a: {b: 12, c: 13}}); + nomatch({a: {b: 12, c: 13}}, {a: {c: 13, b: 12}}); // tested on mongodb + nomatch({a: {}}, {a: {b: 12}}); + nomatch({a: {b: 12}}, {a: {}}); + match( + {a: {b: 12, c: [13, true, false, 2.2, 'a', null, {d: 14}]}}, + {a: {b: 12, c: [13, true, false, 2.2, 'a', null, {d: 14}]}}); + match({a: {b: 12}}, {a: {b: 12}, k: 99}); + + match({a: {b: 12}}, {a: [{b: 12}]}); + nomatch({a: {b: 12}}, {a: [[{b: 12}]]}); + match({a: {b: 12}}, {a: [{b: 11}, {b: 12}, {b: 13}]}); + nomatch({a: {b: 12}}, {a: [{b: 11}, {b: 12, c: 20}, {b: 13}]}); + nomatch({a: {b: 12, c: 20}}, {a: [{b: 11}, {b: 12}, {c: 20}]}); + match({a: {b: 12, c: 20}}, {a: [{b: 11}, {b: 12, c: 20}, {b: 13}]}); + + // null + match({a: null}, {a: null}); + match({a: null}, {b: 12}); + nomatch({a: null}, {a: 12}); + match({a: null}, {a: [1, 2, null, 3]}); // tested on mongodb + nomatch({a: null}, {a: [1, 2, {}, 3]}); // tested on mongodb + + // order comparisons: $lt, $gt, $lte, $gte + match({a: {$lt: 10}}, {a: 9}); + nomatch({a: {$lt: 10}}, {a: 10}); + nomatch({a: {$lt: 10}}, {a: 11}); + + match({a: {$gt: 10}}, {a: 11}); + nomatch({a: {$gt: 10}}, {a: 10}); + nomatch({a: {$gt: 10}}, {a: 9}); + + match({a: {$lte: 10}}, {a: 9}); + match({a: {$lte: 10}}, {a: 10}); + nomatch({a: {$lte: 10}}, {a: 11}); + + match({a: {$gte: 10}}, {a: 11}); + match({a: {$gte: 10}}, {a: 10}); + nomatch({a: {$gte: 10}}, {a: 9}); + + match({a: {$lt: 10}}, {a: [11, 9, 12]}); + nomatch({a: {$lt: 10}}, {a: [11, 12]}); + + // (there's a full suite of ordering test elsewhere) + nomatch({a: {$lt: 'null'}}, {a: null}); + match({a: {$lt: {x: [2, 3, 4]}}}, {a: {x: [1, 3, 4]}}); + match({a: {$gt: {x: [2, 3, 4]}}}, {a: {x: [3, 3, 4]}}); + nomatch({a: {$gt: {x: [2, 3, 4]}}}, {a: {x: [1, 3, 4]}}); + nomatch({a: {$gt: {x: [2, 3, 4]}}}, {a: {x: [2, 3, 4]}}); + nomatch({a: {$lt: {x: [2, 3, 4]}}}, {a: {x: [2, 3, 4]}}); + match({a: {$gte: {x: [2, 3, 4]}}}, {a: {x: [2, 3, 4]}}); + match({a: {$lte: {x: [2, 3, 4]}}}, {a: {x: [2, 3, 4]}}); + + nomatch({a: {$gt: [2, 3]}}, {a: [1, 2]}); // tested against mongodb + + // composition of two qualifiers + nomatch({a: {$lt: 11, $gt: 9}}, {a: 8}); + nomatch({a: {$lt: 11, $gt: 9}}, {a: 9}); + match({a: {$lt: 11, $gt: 9}}, {a: 10}); + nomatch({a: {$lt: 11, $gt: 9}}, {a: 11}); + nomatch({a: {$lt: 11, $gt: 9}}, {a: 12}); + + match({a: {$lt: 11, $gt: 9}}, {a: [8, 9, 10, 11, 12]}); + match({a: {$lt: 11, $gt: 9}}, {a: [8, 9, 11, 12]}); // tested against mongodb + + // $all + match({a: {$all: [1, 2]}}, {a: [1, 2]}); + nomatch({a: {$all: [1, 2, 3]}}, {a: [1, 2]}); + match({a: {$all: [1, 2]}}, {a: [3, 2, 1]}); + match({a: {$all: [1, 'x']}}, {a: [3, 'x', 1]}); + nomatch({a: {$all: ['2']}}, {a: 2}); + nomatch({a: {$all: [2]}}, {a: '2'}); + match({a: {$all: [[1, 2], [1, 3]]}}, {a: [[1, 3], [1, 2], [1, 4]]}); + nomatch({a: {$all: [[1, 2], [1, 3]]}}, {a: [[1, 4], [1, 2], [1, 4]]}); + match({a: {$all: [2, 2]}}, {a: [2]}); // tested against mongodb + nomatch({a: {$all: [2, 3]}}, {a: [2, 2]}); + + nomatch({a: {$all: [1, 2]}}, {a: [[1, 2]]}); // tested against mongodb + nomatch({a: {$all: [1, 2]}}, {}); // tested against mongodb, field doesn't exist + nomatch({a: {$all: [1, 2]}}, {a: {foo: 'bar'}}); // tested against mongodb, field is not an object + nomatch({a: {$all: []}}, {a: []}); + nomatch({a: {$all: []}}, {a: [5]}); + match({a: {$all: [/i/, /e/i]}}, {a: ['foo', 'bEr', 'biz']}); + nomatch({a: {$all: [/i/, /e/i]}}, {a: ['foo', 'bar', 'biz']}); + match({a: {$all: [{b: 3}]}}, {a: [{b: 3}]}); + // Members of $all other than regexps are *equality matches*, not document + // matches. + nomatch({a: {$all: [{b: 3}]}}, {a: [{b: 3, k: 4}]}); + test.throws(() => { + match({a: {$all: [{$gt: 4}]}}, {}); + }); + + // $exists + match({a: {$exists: true}}, {a: 12}); + nomatch({a: {$exists: true}}, {b: 12}); + nomatch({a: {$exists: false}}, {a: 12}); + match({a: {$exists: false}}, {b: 12}); + + match({a: {$exists: true}}, {a: []}); + nomatch({a: {$exists: true}}, {b: []}); + nomatch({a: {$exists: false}}, {a: []}); + match({a: {$exists: false}}, {b: []}); + + match({a: {$exists: true}}, {a: [1]}); + nomatch({a: {$exists: true}}, {b: [1]}); + nomatch({a: {$exists: false}}, {a: [1]}); + match({a: {$exists: false}}, {b: [1]}); + + match({a: {$exists: 1}}, {a: 5}); + match({a: {$exists: 0}}, {b: 5}); + + nomatch({'a.x': {$exists: false}}, {a: [{}, {x: 5}]}); + match({'a.x': {$exists: true}}, {a: [{}, {x: 5}]}); + match({'a.x': {$exists: true}}, {a: [{}, {x: 5}]}); + match({'a.x': {$exists: true}}, {a: {x: []}}); + match({'a.x': {$exists: true}}, {a: {x: null}}); + + // $mod + match({a: {$mod: [10, 1]}}, {a: 11}); + nomatch({a: {$mod: [10, 1]}}, {a: 12}); + match({a: {$mod: [10, 1]}}, {a: [10, 11, 12]}); + nomatch({a: {$mod: [10, 1]}}, {a: [10, 12]}); + [ + 5, + [10], + [10, 1, 2], + 'foo', + {bar: 1}, + [], + ].forEach(badMod => { + test.throws(() => { + match({a: {$mod: badMod}}, {a: 11}); + }); + }); + + // $eq + nomatch({a: {$eq: 1}}, {a: 2}); + match({a: {$eq: 2}}, {a: 2}); + nomatch({a: {$eq: [1]}}, {a: [2]}); + + match({a: {$eq: [1, 2]}}, {a: [1, 2]}); + match({a: {$eq: 1}}, {a: [1, 2]}); + match({a: {$eq: 2}}, {a: [1, 2]}); + nomatch({a: {$eq: 3}}, {a: [1, 2]}); + match({'a.b': {$eq: 1}}, {a: [{b: 1}, {b: 2}]}); + match({'a.b': {$eq: 2}}, {a: [{b: 1}, {b: 2}]}); + nomatch({'a.b': {$eq: 3}}, {a: [{b: 1}, {b: 2}]}); + + match({a: {$eq: {x: 1}}}, {a: {x: 1}}); + nomatch({a: {$eq: {x: 1}}}, {a: {x: 2}}); + nomatch({a: {$eq: {x: 1}}}, {a: {x: 1, y: 2}}); + + // $ne + match({a: {$ne: 1}}, {a: 2}); + nomatch({a: {$ne: 2}}, {a: 2}); + match({a: {$ne: [1]}}, {a: [2]}); + + nomatch({a: {$ne: [1, 2]}}, {a: [1, 2]}); // all tested against mongodb + nomatch({a: {$ne: 1}}, {a: [1, 2]}); + nomatch({a: {$ne: 2}}, {a: [1, 2]}); + match({a: {$ne: 3}}, {a: [1, 2]}); + nomatch({'a.b': {$ne: 1}}, {a: [{b: 1}, {b: 2}]}); + nomatch({'a.b': {$ne: 2}}, {a: [{b: 1}, {b: 2}]}); + match({'a.b': {$ne: 3}}, {a: [{b: 1}, {b: 2}]}); + + nomatch({a: {$ne: {x: 1}}}, {a: {x: 1}}); + match({a: {$ne: {x: 1}}}, {a: {x: 2}}); + match({a: {$ne: {x: 1}}}, {a: {x: 1, y: 2}}); + + // This query means: All 'a.b' must be non-5, and some 'a.b' must be >6. + match({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 10}]}); + nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 4}]}); + nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 2}, {b: 5}]}); + nomatch({'a.b': {$ne: 5, $gt: 6}}, {a: [{b: 10}, {b: 5}]}); + // Should work the same if the branch is at the bottom. + match({a: {$ne: 5, $gt: 6}}, {a: [2, 10]}); + nomatch({a: {$ne: 5, $gt: 6}}, {a: [2, 4]}); + nomatch({a: {$ne: 5, $gt: 6}}, {a: [2, 5]}); + nomatch({a: {$ne: 5, $gt: 6}}, {a: [10, 5]}); + + // $in + match({a: {$in: [1, 2, 3]}}, {a: 2}); + nomatch({a: {$in: [1, 2, 3]}}, {a: 4}); + match({a: {$in: [[1], [2], [3]]}}, {a: [2]}); + nomatch({a: {$in: [[1], [2], [3]]}}, {a: [4]}); + match({a: {$in: [{b: 1}, {b: 2}, {b: 3}]}}, {a: {b: 2}}); + nomatch({a: {$in: [{b: 1}, {b: 2}, {b: 3}]}}, {a: {b: 4}}); + + match({a: {$in: [1, 2, 3]}}, {a: [2]}); // tested against mongodb + match({a: {$in: [{x: 1}, {x: 2}, {x: 3}]}}, {a: [{x: 2}]}); + match({a: {$in: [1, 2, 3]}}, {a: [4, 2]}); + nomatch({a: {$in: [1, 2, 3]}}, {a: [4]}); + + match({a: {$in: ['x', /foo/i]}}, {a: 'x'}); + match({a: {$in: ['x', /foo/i]}}, {a: 'fOo'}); + match({a: {$in: ['x', /foo/i]}}, {a: ['f', 'fOo']}); + nomatch({a: {$in: ['x', /foo/i]}}, {a: ['f', 'fOx']}); + + match({a: {$in: [1, null]}}, {}); + match({'a.b': {$in: [1, null]}}, {}); + match({'a.b': {$in: [1, null]}}, {a: {}}); + match({'a.b': {$in: [1, null]}}, {a: {b: null}}); + nomatch({'a.b': {$in: [1, null]}}, {a: {b: 5}}); + nomatch({'a.b': {$in: [1]}}, {a: {b: null}}); + nomatch({'a.b': {$in: [1]}}, {a: {}}); + nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}]}); + match({'a.b': {$in: [1, null]}}, {a: [{b: 5}, {}]}); + nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}, []]}); + nomatch({'a.b': {$in: [1, null]}}, {a: [{b: 5}, 5]}); + + // $nin + nomatch({a: {$nin: [1, 2, 3]}}, {a: 2}); + match({a: {$nin: [1, 2, 3]}}, {a: 4}); + nomatch({a: {$nin: [[1], [2], [3]]}}, {a: [2]}); + match({a: {$nin: [[1], [2], [3]]}}, {a: [4]}); + nomatch({a: {$nin: [{b: 1}, {b: 2}, {b: 3}]}}, {a: {b: 2}}); + match({a: {$nin: [{b: 1}, {b: 2}, {b: 3}]}}, {a: {b: 4}}); + + nomatch({a: {$nin: [1, 2, 3]}}, {a: [2]}); // tested against mongodb + nomatch({a: {$nin: [{x: 1}, {x: 2}, {x: 3}]}}, {a: [{x: 2}]}); + nomatch({a: {$nin: [1, 2, 3]}}, {a: [4, 2]}); + nomatch({'a.b': {$nin: [1, 2, 3]}}, {a: [{b: 4}, {b: 2}]}); + match({a: {$nin: [1, 2, 3]}}, {a: [4]}); + match({'a.b': {$nin: [1, 2, 3]}}, {a: [{b: 4}]}); + + nomatch({a: {$nin: ['x', /foo/i]}}, {a: 'x'}); + nomatch({a: {$nin: ['x', /foo/i]}}, {a: 'fOo'}); + nomatch({a: {$nin: ['x', /foo/i]}}, {a: ['f', 'fOo']}); + match({a: {$nin: ['x', /foo/i]}}, {a: ['f', 'fOx']}); + + nomatch({a: {$nin: [1, null]}}, {}); + nomatch({'a.b': {$nin: [1, null]}}, {}); + nomatch({'a.b': {$nin: [1, null]}}, {a: {}}); + nomatch({'a.b': {$nin: [1, null]}}, {a: {b: null}}); + match({'a.b': {$nin: [1, null]}}, {a: {b: 5}}); + match({'a.b': {$nin: [1]}}, {a: {b: null}}); + match({'a.b': {$nin: [1]}}, {a: {}}); + match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}]}); + nomatch({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, {}]}); + match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, []]}); + match({'a.b': {$nin: [1, null]}}, {a: [{b: 5}, 5]}); + + // $size + match({a: {$size: 0}}, {a: []}); + match({a: {$size: 1}}, {a: [2]}); + match({a: {$size: 2}}, {a: [2, 2]}); + nomatch({a: {$size: 0}}, {a: [2]}); + nomatch({a: {$size: 1}}, {a: []}); + nomatch({a: {$size: 1}}, {a: [2, 2]}); + nomatch({a: {$size: 0}}, {a: '2'}); + nomatch({a: {$size: 1}}, {a: '2'}); + nomatch({a: {$size: 2}}, {a: '2'}); + + nomatch({a: {$size: 2}}, {a: [[2, 2]]}); // tested against mongodb + + + // $bitsAllClear - number + match({a: {$bitsAllClear: [0, 1, 2, 3]}}, {a: 0}); + match({a: {$bitsAllClear: [0, 1, 2, 3]}}, {a: 0b10000}); + nomatch({a: {$bitsAllClear: [0, 1, 2, 3]}}, {a: 0b1}); + nomatch({a: {$bitsAllClear: [0, 1, 2, 3]}}, {a: 0b10}); + nomatch({a: {$bitsAllClear: [0, 1, 2, 3]}}, {a: 0b100}); + nomatch({a: {$bitsAllClear: [0, 1, 2, 3]}}, {a: 0b1000}); + + // $bitsAllClear - buffer + match({a: {$bitsAllClear: new Uint8Array([3])}}, {a: new Uint8Array([4])}); + match({a: {$bitsAllClear: new Uint8Array([0, 1])}}, {a: new Uint8Array([255])}); // 256 should not be set for 255. + match({a: {$bitsAllClear: new Uint8Array([3])}}, {a: 4 }); + + match({a: {$bitsAllClear: new Uint8Array([3])}}, {a: 0 }); + + // $bitsAllSet - number + match({a: {$bitsAllSet: [0, 1, 2, 3]}}, {a: 0b1111}); + nomatch({a: {$bitsAllSet: [0, 1, 2, 3]}}, {a: 0b111}); + nomatch({a: {$bitsAllSet: [0, 1, 2, 3]}}, {a: 256}); + nomatch({a: {$bitsAllSet: [0, 1, 2, 3]}}, {a: 50000}); + match({a: {$bitsAllSet: [0, 1, 2]}}, {a: 15}); + match({a: {$bitsAllSet: [0, 12]}}, {a: 0b1000000000001}); + nomatch({a: {$bitsAllSet: [0, 12]}}, {a: 0b1000000000000}); + nomatch({a: {$bitsAllSet: [0, 12]}}, {a: 0b1}); + + // $bitsAllSet - buffer + match({a: {$bitsAllSet: new Uint8Array([3])}}, {a: new Uint8Array([3])}); + match({a: {$bitsAllSet: new Uint8Array([7])}}, {a: new Uint8Array([15])}); + match({a: {$bitsAllSet: new Uint8Array([3])}}, {a: 3 }); + + // $bitsAnySet - number + match({a: {$bitsAnySet: [0, 1, 2, 3]}}, {a: 0b1}); + match({a: {$bitsAnySet: [0, 1, 2, 3]}}, {a: 0b10}); + match({a: {$bitsAnySet: [0, 1, 2, 3]}}, {a: 0b100}); + match({a: {$bitsAnySet: [0, 1, 2, 3]}}, {a: 0b1000}); + match({a: {$bitsAnySet: [4]}}, {a: 0b10000}); + nomatch({a: {$bitsAnySet: [0, 1, 2, 3]}}, {a: 0b10000}); + nomatch({a: {$bitsAnySet: [0, 1, 2, 3]}}, {a: 0}); + + // $bitsAnySet - buffer + match({a: {$bitsAnySet: new Uint8Array([3])}}, {a: new Uint8Array([7])}); + match({a: {$bitsAnySet: new Uint8Array([15])}}, {a: new Uint8Array([7])}); + match({a: {$bitsAnySet: new Uint8Array([3])}}, {a: 1 }); + + // $bitsAnyClear - number + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0}); + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b1}); + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b10}); + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b100}); + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b1000}); + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b10000}); + nomatch({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b1111}); + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b111}); + nomatch({a: {$bitsAnyClear: [0, 1, 2]}}, {a: 0b111}); + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b11}); + nomatch({a: {$bitsAnyClear: [0, 1]}}, {a: 0b11}); + match({a: {$bitsAnyClear: [0, 1, 2, 3]}}, {a: 0b1}); + nomatch({a: {$bitsAnyClear: [0]}}, {a: 0b1}); + nomatch({a: {$bitsAnyClear: [4]}}, {a: 0b10000}); + + // $bitsAnyClear - buffer + match({a: {$bitsAnyClear: new Uint8Array([8])}}, {a: new Uint8Array([7])}); + match({a: {$bitsAnyClear: new Uint8Array([1])}}, {a: new Uint8Array([0])}); + match({a: {$bitsAnyClear: new Uint8Array([1])}}, {a: 4 }); + + // taken from: https://github.com/mongodb/mongo/blob/master/jstests/core/bittest.js + const c = new LocalCollection; + function matchCount(query, count) { + const matches = c.find(query).count(); + if (matches !== count) { + test.fail({message: `minimongo match count failure: matched ${matches} times, but should match ${count} times`, + query: JSON.stringify(query), + count: JSON.stringify(count), + }); + } + } + + // Tests on numbers. + + c.insert({a: 0}); + c.insert({a: 1}); + c.insert({a: 54}); + c.insert({a: 88}); + c.insert({a: 255}); + + // Tests with bitmask. + matchCount({a: {$bitsAllSet: 0}}, 5); + matchCount({a: {$bitsAllSet: 1}}, 2); + matchCount({a: {$bitsAllSet: 16}}, 3); + matchCount({a: {$bitsAllSet: 54}}, 2); + matchCount({a: {$bitsAllSet: 55}}, 1); + matchCount({a: {$bitsAllSet: 88}}, 2); + matchCount({a: {$bitsAllSet: 255}}, 1); + matchCount({a: {$bitsAllClear: 0}}, 5); + matchCount({a: {$bitsAllClear: 1}}, 3); + matchCount({a: {$bitsAllClear: 16}}, 2); + matchCount({a: {$bitsAllClear: 129}}, 3); + matchCount({a: {$bitsAllClear: 255}}, 1); + matchCount({a: {$bitsAnySet: 0}}, 0); + matchCount({a: {$bitsAnySet: 9}}, 3); + matchCount({a: {$bitsAnySet: 255}}, 4); + matchCount({a: {$bitsAnyClear: 0}}, 0); + matchCount({a: {$bitsAnyClear: 18}}, 3); + matchCount({a: {$bitsAnyClear: 24}}, 3); + matchCount({a: {$bitsAnyClear: 255}}, 4); + + // Tests with array of bit positions. + matchCount({a: {$bitsAllSet: []}}, 5); + matchCount({a: {$bitsAllSet: [0]}}, 2); + matchCount({a: {$bitsAllSet: [4]}}, 3); + matchCount({a: {$bitsAllSet: [1, 2, 4, 5]}}, 2); + matchCount({a: {$bitsAllSet: [0, 1, 2, 4, 5]}}, 1); + matchCount({a: {$bitsAllSet: [3, 4, 6]}}, 2); + matchCount({a: {$bitsAllSet: [0, 1, 2, 3, 4, 5, 6, 7]}}, 1); + matchCount({a: {$bitsAllClear: []}}, 5); + matchCount({a: {$bitsAllClear: [0]}}, 3); + matchCount({a: {$bitsAllClear: [4]}}, 2); + matchCount({a: {$bitsAllClear: [1, 7]}}, 3); + matchCount({a: {$bitsAllClear: [0, 1, 2, 3, 4, 5, 6, 7]}}, 1); + matchCount({a: {$bitsAnySet: []}}, 0); + matchCount({a: {$bitsAnySet: [1, 3]}}, 3); + matchCount({a: {$bitsAnySet: [0, 1, 2, 3, 4, 5, 6, 7]}}, 4); + matchCount({a: {$bitsAnyClear: []}}, 0); + matchCount({a: {$bitsAnyClear: [1, 4]}}, 3); + matchCount({a: {$bitsAnyClear: [3, 4]}}, 3); + matchCount({a: {$bitsAnyClear: [0, 1, 2, 3, 4, 5, 6, 7]}}, 4); + + // Tests with multiple predicates. + matchCount({a: {$bitsAllSet: 54, $bitsAllClear: 201}}, 1); + + // Tests on negative numbers + + c.remove({}); + c.insert({a: -0}); + c.insert({a: -1}); + c.insert({a: -54}); + + // Tests with bitmask. + matchCount({a: {$bitsAllSet: 0}}, 3); + matchCount({a: {$bitsAllSet: 2}}, 2); + matchCount({a: {$bitsAllSet: 127}}, 1); + matchCount({a: {$bitsAllSet: 74}}, 2); + matchCount({a: {$bitsAllClear: 0}}, 3); + matchCount({a: {$bitsAllClear: 53}}, 2); + matchCount({a: {$bitsAllClear: 127}}, 1); + matchCount({a: {$bitsAnySet: 0}}, 0); + matchCount({a: {$bitsAnySet: 2}}, 2); + matchCount({a: {$bitsAnySet: 127}}, 2); + matchCount({a: {$bitsAnyClear: 0}}, 0); + matchCount({a: {$bitsAnyClear: 53}}, 2); + matchCount({a: {$bitsAnyClear: 127}}, 2); + + // Tests with array of bit positions. + const allPositions = []; + for (let i = 0; i < 64; i++) { + allPositions.push(i); + } + + matchCount({a: {$bitsAllSet: []}}, 3); + matchCount({a: {$bitsAllSet: [1]}}, 2); + matchCount({a: {$bitsAllSet: allPositions}}, 1); + matchCount({a: {$bitsAllSet: [1, 7, 6, 3, 100]}}, 2); + matchCount({a: {$bitsAllClear: []}}, 3); + matchCount({a: {$bitsAllClear: [5, 4, 2, 0]}}, 2); + matchCount({a: {$bitsAllClear: allPositions}}, 1); + matchCount({a: {$bitsAnySet: []}}, 0); + matchCount({a: {$bitsAnySet: [1]}}, 2); + matchCount({a: {$bitsAnySet: allPositions}}, 2); + matchCount({a: {$bitsAnyClear: []}}, 0); + matchCount({a: {$bitsAnyClear: [0, 2, 4, 5, 100]}}, 2); + matchCount({a: {$bitsAnyClear: allPositions}}, 2); + + // Tests with multiple predicates. + matchCount({a: {$bitsAllSet: 74, $bitsAllClear: 53}}, 1); + + // Tests on BinData. + + c.remove({}); + c.insert({a: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}); + c.insert({a: EJSON.parse('{"$binary": "AANgAAAAAAAAAAAAAAAAAAAAAAAA"}')}); + c.insert({a: EJSON.parse('{"$binary": "JANgqwetkqwklEWRbWERKKJREtbq"}')}); + c.insert({a: EJSON.parse('{"$binary": "////////////////////////////"}')}); + + // Tests with binary string bitmask. + matchCount({a: {$bitsAllSet: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 4); + matchCount({a: {$bitsAllSet: EJSON.parse('{"$binary": "AANgAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 3); + matchCount({a: {$bitsAllSet: EJSON.parse('{"$binary": "JANgqwetkqwklEWRbWERKKJREtbq"}')}}, 2); + matchCount({a: {$bitsAllSet: EJSON.parse('{"$binary": "////////////////////////////"}')}}, 1); + matchCount({a: {$bitsAllClear: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 4); + matchCount({a: {$bitsAllClear: EJSON.parse('{"$binary": "AAyfAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 3); + matchCount({a: {$bitsAllClear: EJSON.parse('{"$binary": "JAyfqwetkqwklEWRbWERKKJREtbq"}')}}, 2); + matchCount({a: {$bitsAllClear: EJSON.parse('{"$binary": "////////////////////////////"}')}}, 1); + matchCount({a: {$bitsAnySet: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 0); + matchCount({a: {$bitsAnySet: EJSON.parse('{"$binary": "AAyfAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 1); + matchCount({a: {$bitsAnySet: EJSON.parse('{"$binary": "JAyfqwetkqwklEWRbWERKKJREtbq"}')}}, 2); + matchCount({a: {$bitsAnySet: EJSON.parse('{"$binary": "////////////////////////////"}')}}, 3); + matchCount({a: {$bitsAnyClear: EJSON.parse('{"$binary": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 0); + matchCount({a: {$bitsAnyClear: EJSON.parse('{"$binary": "AANgAAAAAAAAAAAAAAAAAAAAAAAA"}')}}, 1); + matchCount({a: {$bitsAnyClear: EJSON.parse('{"$binary": "JANgqwetkqwklEWRbWERKKJREtbq"}')}}, 2); + matchCount({a: {$bitsAnyClear: EJSON.parse('{"$binary": "////////////////////////////"}')}}, 3); + + // Tests with multiple predicates. + matchCount({ + a: { + $bitsAllSet: EJSON.parse('{"$binary": "AANgAAAAAAAAAAAAAAAAAAAAAAAA"}'), + $bitsAllClear: EJSON.parse('{"$binary": "//yf////////////////////////"}'), + }, + }, 1); + + c.remove({}); + + nomatch({a: {$bitsAllSet: 1}}, {a: false}); + nomatch({a: {$bitsAllSet: 1}}, {a: NaN}); + nomatch({a: {$bitsAllSet: 1}}, {a: Infinity}); + nomatch({a: {$bitsAllSet: 1}}, {a: null}); + nomatch({a: {$bitsAllSet: 1}}, {a: 'asdf'}); + nomatch({a: {$bitsAllSet: 1}}, {a: ['a', 'b']}); + nomatch({a: {$bitsAllSet: 1}}, {a: {foo: 'bar'}}); + nomatch({a: {$bitsAllSet: 1}}, {a: 1.2}); + nomatch({a: {$bitsAllSet: 1}}, {a: '1'}); + + [ + false, + NaN, + Infinity, + null, + 'asdf', + ['a', 'b'], + {foo: 'bar'}, + 1.2, + '1', + [0, -1], + ].forEach(badValue => { + test.throws(() => { + match({a: {$bitsAllSet: badValue}}, {a: 42}); + }); + }); + + // $type + match({a: {$type: 1}}, {a: 1.1}); + match({a: {$type: 1}}, {a: 1}); + nomatch({a: {$type: 1}}, {a: '1'}); + match({a: {$type: 2}}, {a: '1'}); + nomatch({a: {$type: 2}}, {a: 1}); + match({a: {$type: 3}}, {a: {}}); + match({a: {$type: 3}}, {a: {b: 2}}); + nomatch({a: {$type: 3}}, {a: []}); + nomatch({a: {$type: 3}}, {a: [1]}); + nomatch({a: {$type: 3}}, {a: null}); + match({a: {$type: 5}}, {a: EJSON.newBinary(0)}); + match({a: {$type: 5}}, {a: EJSON.newBinary(4)}); + nomatch({a: {$type: 5}}, {a: []}); + nomatch({a: {$type: 5}}, {a: [42]}); + match({a: {$type: 7}}, {a: new MongoID.ObjectID()}); + nomatch({a: {$type: 7}}, {a: '1234567890abcd1234567890'}); + match({a: {$type: 8}}, {a: true}); + match({a: {$type: 8}}, {a: false}); + nomatch({a: {$type: 8}}, {a: 'true'}); + nomatch({a: {$type: 8}}, {a: 0}); + nomatch({a: {$type: 8}}, {a: null}); + nomatch({a: {$type: 8}}, {a: ''}); + nomatch({a: {$type: 8}}, {}); + match({a: {$type: 9}}, {a: new Date}); + nomatch({a: {$type: 9}}, {a: +new Date}); + match({a: {$type: 10}}, {a: null}); + nomatch({a: {$type: 10}}, {a: false}); + nomatch({a: {$type: 10}}, {a: ''}); + nomatch({a: {$type: 10}}, {a: 0}); + nomatch({a: {$type: 10}}, {}); + match({a: {$type: 11}}, {a: /x/}); + nomatch({a: {$type: 11}}, {a: 'x'}); + nomatch({a: {$type: 11}}, {}); + + // The normal rule for {$type:4} (4 means array) is that it NOT good enough to + // just have an array that's the leaf that matches the path. (An array inside + // that array is good, though.) + nomatch({a: {$type: 4}}, {a: []}); + nomatch({a: {$type: 4}}, {a: [1]}); // tested against mongodb + match({a: {$type: 1}}, {a: [1]}); + nomatch({a: {$type: 2}}, {a: [1]}); + match({a: {$type: 1}}, {a: ['1', 1]}); + match({a: {$type: 2}}, {a: ['1', 1]}); + nomatch({a: {$type: 3}}, {a: ['1', 1]}); + nomatch({a: {$type: 4}}, {a: ['1', 1]}); + nomatch({a: {$type: 1}}, {a: ['1', []]}); + match({a: {$type: 2}}, {a: ['1', []]}); + match({a: {$type: 4}}, {a: ['1', []]}); // tested against mongodb + // An exception to the normal rule is that an array found via numeric index is + // examined itself, and its elements are not. + match({'a.0': {$type: 4}}, {a: [[0]]}); + nomatch({'a.0': {$type: 1}}, {a: [[0]]}); + + // regular expressions + match({a: /a/}, {a: 'cat'}); + nomatch({a: /a/}, {a: 'cut'}); + nomatch({a: /a/}, {a: 'CAT'}); + match({a: /a/i}, {a: 'CAT'}); + match({a: /a/}, {a: ['foo', 'bar']}); // search within array... + nomatch({a: /,/}, {a: ['foo', 'bar']}); // but not by stringifying + match({a: {$regex: 'a'}}, {a: ['foo', 'bar']}); + nomatch({a: {$regex: ','}}, {a: ['foo', 'bar']}); + match({a: {$regex: /a/}}, {a: 'cat'}); + nomatch({a: {$regex: /a/}}, {a: 'cut'}); + nomatch({a: {$regex: /a/}}, {a: 'CAT'}); + match({a: {$regex: /a/i}}, {a: 'CAT'}); + match({a: {$regex: /a/, $options: 'i'}}, {a: 'CAT'}); // tested + match({a: {$regex: /a/i, $options: 'i'}}, {a: 'CAT'}); // tested + nomatch({a: {$regex: /a/i, $options: ''}}, {a: 'CAT'}); // tested + match({a: {$regex: 'a'}}, {a: 'cat'}); + nomatch({a: {$regex: 'a'}}, {a: 'cut'}); + nomatch({a: {$regex: 'a'}}, {a: 'CAT'}); + match({a: {$regex: 'a', $options: 'i'}}, {a: 'CAT'}); + match({a: {$regex: '', $options: 'i'}}, {a: 'foo'}); + nomatch({a: {$regex: '', $options: 'i'}}, {}); + nomatch({a: {$regex: '', $options: 'i'}}, {a: 5}); + nomatch({a: /undefined/}, {}); + nomatch({a: {$regex: 'undefined'}}, {}); + nomatch({a: /xxx/}, {}); + nomatch({a: {$regex: 'xxx'}}, {}); + + // GitHub issue #2817: + // Regexps with a global flag ('g') keep a state when tested against the same + // string. Selector shouldn't return different result for similar documents + // because of this state. + const reusedRegexp = /sh/ig; + match({a: reusedRegexp}, {a: 'Shorts'}); + match({a: reusedRegexp}, {a: 'Shorts'}); + match({a: reusedRegexp}, {a: 'Shorts'}); + + match({a: {$regex: reusedRegexp}}, {a: 'Shorts'}); + match({a: {$regex: reusedRegexp}}, {a: 'Shorts'}); + match({a: {$regex: reusedRegexp}}, {a: 'Shorts'}); + + test.throws(() => { + match({a: {$options: 'i'}}, {a: 12}); + }); + + match({a: /a/}, {a: ['dog', 'cat']}); + nomatch({a: /a/}, {a: ['dog', 'puppy']}); + + // we don't support regexps in minimongo very well (eg, there's no EJSON + // encoding so it won't go over the wire), but run these tests anyway + match({a: /a/}, {a: /a/}); + match({a: /a/}, {a: ['x', /a/]}); + nomatch({a: /a/}, {a: /a/i}); + nomatch({a: /a/m}, {a: /a/}); + nomatch({a: /a/}, {a: /b/}); + nomatch({a: /5/}, {a: 5}); + nomatch({a: /t/}, {a: true}); + match({a: /m/i}, {a: ['x', 'xM']}); + + test.throws(() => { + match({a: {$regex: /a/, $options: 'x'}}, {a: 'cat'}); + }); + test.throws(() => { + match({a: {$regex: /a/, $options: 's'}}, {a: 'cat'}); + }); + + // $not + match({x: {$not: {$gt: 7}}}, {x: 6}); + nomatch({x: {$not: {$gt: 7}}}, {x: 8}); + match({x: {$not: {$lt: 10, $gt: 7}}}, {x: 11}); + nomatch({x: {$not: {$lt: 10, $gt: 7}}}, {x: 9}); + match({x: {$not: {$lt: 10, $gt: 7}}}, {x: 6}); + + match({x: {$not: {$gt: 7}}}, {x: [2, 3, 4]}); + match({'x.y': {$not: {$gt: 7}}}, {x: [{y: 2}, {y: 3}, {y: 4}]}); + nomatch({x: {$not: {$gt: 7}}}, {x: [2, 3, 4, 10]}); + nomatch({'x.y': {$not: {$gt: 7}}}, {x: [{y: 2}, {y: 3}, {y: 4}, {y: 10}]}); + + match({x: {$not: /a/}}, {x: 'dog'}); + nomatch({x: {$not: /a/}}, {x: 'cat'}); + match({x: {$not: /a/}}, {x: ['dog', 'puppy']}); + nomatch({x: {$not: /a/}}, {x: ['kitten', 'cat']}); + + // dotted keypaths: bare values + match({'a.b': 1}, {a: {b: 1}}); + nomatch({'a.b': 1}, {a: {b: 2}}); + match({'a.b': [1, 2, 3]}, {a: {b: [1, 2, 3]}}); + nomatch({'a.b': [1, 2, 3]}, {a: {b: [4]}}); + match({'a.b': /a/}, {a: {b: 'cat'}}); + nomatch({'a.b': /a/}, {a: {b: 'dog'}}); + match({'a.b.c': null}, {}); + match({'a.b.c': null}, {a: 1}); + match({'a.b': null}, {a: 1}); + match({'a.b.c': null}, {a: {b: 4}}); + + // dotted keypaths, nulls, numeric indices, arrays + nomatch({'a.b': null}, {a: [1]}); + match({'a.b': []}, {a: {b: []}}); + const big = {a: [{b: 1}, 2, {}, {b: [3, 4]}]}; + match({'a.b': 1}, big); + match({'a.b': [3, 4]}, big); + match({'a.b': 3}, big); + match({'a.b': 4}, big); + match({'a.b': null}, big); // matches on slot 2 + match({'a.1': 8}, {a: [7, 8, 9]}); + nomatch({'a.1': 7}, {a: [7, 8, 9]}); + nomatch({'a.1': null}, {a: [7, 8, 9]}); + match({'a.1': [8, 9]}, {a: [7, [8, 9]]}); + nomatch({'a.1': 6}, {a: [[6, 7], [8, 9]]}); + nomatch({'a.1': 7}, {a: [[6, 7], [8, 9]]}); + nomatch({'a.1': 8}, {a: [[6, 7], [8, 9]]}); + nomatch({'a.1': 9}, {a: [[6, 7], [8, 9]]}); + match({'a.1': 2}, {a: [0, {1: 2}, 3]}); + match({'a.1': {1: 2}}, {a: [0, {1: 2}, 3]}); + match({'x.1.y': 8}, {x: [7, {y: 8}, 9]}); + // comes from trying '1' as key in the plain object + match({'x.1.y': null}, {x: [7, {y: 8}, 9]}); + match({'a.1.b': 9}, {a: [7, {b: 9}, {1: {b: 'foo'}}]}); + match({'a.1.b': 'foo'}, {a: [7, {b: 9}, {1: {b: 'foo'}}]}); + match({'a.1.b': null}, {a: [7, {b: 9}, {1: {b: 'foo'}}]}); + match({'a.1.b': 2}, {a: [1, [{b: 2}], 3]}); + nomatch({'a.1.b': null}, {a: [1, [{b: 2}], 3]}); + // this is new behavior in mongo 2.5 + nomatch({'a.0.b': null}, {a: [5]}); + match({'a.1': 4}, {a: [{1: 4}, 5]}); + match({'a.1': 5}, {a: [{1: 4}, 5]}); + nomatch({'a.1': null}, {a: [{1: 4}, 5]}); + match({'a.1.foo': 4}, {a: [{1: {foo: 4}}, {foo: 5}]}); + match({'a.1.foo': 5}, {a: [{1: {foo: 4}}, {foo: 5}]}); + match({'a.1.foo': null}, {a: [{1: {foo: 4}}, {foo: 5}]}); + + // trying to access a dotted field that is undefined at some point + // down the chain + nomatch({'a.b': 1}, {x: 2}); + nomatch({'a.b.c': 1}, {a: {x: 2}}); + nomatch({'a.b.c': 1}, {a: {b: {x: 2}}}); + nomatch({'a.b.c': 1}, {a: {b: 1}}); + nomatch({'a.b.c': 1}, {a: {b: 0}}); + + // dotted keypaths: literal objects + match({'a.b': {c: 1}}, {a: {b: {c: 1}}}); + nomatch({'a.b': {c: 1}}, {a: {b: {c: 2}}}); + nomatch({'a.b': {c: 1}}, {a: {b: 2}}); + match({'a.b': {c: 1, d: 2}}, {a: {b: {c: 1, d: 2}}}); + nomatch({'a.b': {c: 1, d: 2}}, {a: {b: {c: 1, d: 1}}}); + nomatch({'a.b': {c: 1, d: 2}}, {a: {b: {d: 2}}}); + + // dotted keypaths: $ operators + match({'a.b': {$in: [1, 2, 3]}}, {a: {b: [2]}}); // tested against mongodb + match({'a.b': {$in: [{x: 1}, {x: 2}, {x: 3}]}}, {a: {b: [{x: 2}]}}); + match({'a.b': {$in: [1, 2, 3]}}, {a: {b: [4, 2]}}); + nomatch({'a.b': {$in: [1, 2, 3]}}, {a: {b: [4]}}); + + // $or + test.throws(() => { + match({$or: []}, {}); + }); + test.throws(() => { + match({$or: [5]}, {}); + }); + test.throws(() => { + match({$or: []}, {a: 1}); + }); + match({$or: [{a: 1}]}, {a: 1}); + nomatch({$or: [{b: 2}]}, {a: 1}); + match({$or: [{a: 1}, {b: 2}]}, {a: 1}); + nomatch({$or: [{c: 3}, {d: 4}]}, {a: 1}); + match({$or: [{a: 1}, {b: 2}]}, {a: [1, 2, 3]}); + nomatch({$or: [{a: 1}, {b: 2}]}, {c: [1, 2, 3]}); + nomatch({$or: [{a: 1}, {b: 2}]}, {a: [2, 3, 4]}); + match({$or: [{a: 1}, {a: 2}]}, {a: 1}); + match({$or: [{a: 1}, {a: 2}], b: 2}, {a: 1, b: 2}); + nomatch({$or: [{a: 2}, {a: 3}], b: 2}, {a: 1, b: 2}); + nomatch({$or: [{a: 1}, {a: 2}], b: 3}, {a: 1, b: 2}); + + // Combining $or with equality + match({x: 1, $or: [{a: 1}, {b: 1}]}, {x: 1, b: 1}); + match({$or: [{a: 1}, {b: 1}], x: 1}, {x: 1, b: 1}); + nomatch({x: 1, $or: [{a: 1}, {b: 1}]}, {b: 1}); + nomatch({x: 1, $or: [{a: 1}, {b: 1}]}, {x: 1}); + + // $or and $lt, $lte, $gt, $gte + match({$or: [{a: {$lte: 1}}, {a: 2}]}, {a: 1}); + nomatch({$or: [{a: {$lt: 1}}, {a: 2}]}, {a: 1}); + match({$or: [{a: {$gte: 1}}, {a: 2}]}, {a: 1}); + nomatch({$or: [{a: {$gt: 1}}, {a: 2}]}, {a: 1}); + match({$or: [{b: {$gt: 1}}, {b: {$lt: 3}}]}, {b: 2}); + nomatch({$or: [{b: {$lt: 1}}, {b: {$gt: 3}}]}, {b: 2}); + + // $or and $in + match({$or: [{a: {$in: [1, 2, 3]}}]}, {a: 1}); + nomatch({$or: [{a: {$in: [4, 5, 6]}}]}, {a: 1}); + match({$or: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {a: 1}); + match({$or: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {b: 2}); + nomatch({$or: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {c: 3}); + match({$or: [{a: {$in: [1, 2, 3]}}, {b: {$in: [1, 2, 3]}}]}, {b: 2}); + nomatch({$or: [{a: {$in: [1, 2, 3]}}, {b: {$in: [4, 5, 6]}}]}, {b: 2}); + + // $or and $nin + nomatch({$or: [{a: {$nin: [1, 2, 3]}}]}, {a: 1}); + match({$or: [{a: {$nin: [4, 5, 6]}}]}, {a: 1}); + nomatch({$or: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {a: 1}); + match({$or: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {b: 2}); + match({$or: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {c: 3}); + match({$or: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {b: 2}); + nomatch({$or: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {a: 1, b: 2}); + match({$or: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [4, 5, 6]}}]}, {b: 2}); + + // $or and dot-notation + match({$or: [{'a.b': 1}, {'a.b': 2}]}, {a: {b: 1}}); + match({$or: [{'a.b': 1}, {'a.c': 1}]}, {a: {b: 1}}); + nomatch({$or: [{'a.b': 2}, {'a.c': 1}]}, {a: {b: 1}}); + + // $or and nested objects + match({$or: [{a: {b: 1, c: 2}}, {a: {b: 2, c: 1}}]}, {a: {b: 1, c: 2}}); + nomatch({$or: [{a: {b: 1, c: 3}}, {a: {b: 2, c: 1}}]}, {a: {b: 1, c: 2}}); + + // $or and regexes + match({$or: [{a: /a/}]}, {a: 'cat'}); + nomatch({$or: [{a: /o/}]}, {a: 'cat'}); + match({$or: [{a: /a/}, {a: /o/}]}, {a: 'cat'}); + nomatch({$or: [{a: /i/}, {a: /o/}]}, {a: 'cat'}); + match({$or: [{a: /i/}, {b: /o/}]}, {a: 'cat', b: 'dog'}); + + // $or and $ne + match({$or: [{a: {$ne: 1}}]}, {}); + nomatch({$or: [{a: {$ne: 1}}]}, {a: 1}); + match({$or: [{a: {$ne: 1}}]}, {a: 2}); + match({$or: [{a: {$ne: 1}}]}, {b: 1}); + match({$or: [{a: {$ne: 1}}, {a: {$ne: 2}}]}, {a: 1}); + match({$or: [{a: {$ne: 1}}, {b: {$ne: 1}}]}, {a: 1}); + nomatch({$or: [{a: {$ne: 1}}, {b: {$ne: 2}}]}, {a: 1, b: 2}); + + // $or and $not + match({$or: [{a: {$not: {$mod: [10, 1]}}}]}, {}); + nomatch({$or: [{a: {$not: {$mod: [10, 1]}}}]}, {a: 1}); + match({$or: [{a: {$not: {$mod: [10, 1]}}}]}, {a: 2}); + match({$or: [{a: {$not: {$mod: [10, 1]}}}, {a: {$not: {$mod: [10, 2]}}}]}, {a: 1}); + nomatch({$or: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 1}); + match({$or: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 2}); + match({$or: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 3}); + // this is possibly an open-ended task, so we stop here ... + + // $nor + test.throws(() => { + match({$nor: []}, {}); + }); + test.throws(() => { + match({$nor: [5]}, {}); + }); + test.throws(() => { + match({$nor: []}, {a: 1}); + }); + nomatch({$nor: [{a: 1}]}, {a: 1}); + match({$nor: [{b: 2}]}, {a: 1}); + nomatch({$nor: [{a: 1}, {b: 2}]}, {a: 1}); + match({$nor: [{c: 3}, {d: 4}]}, {a: 1}); + nomatch({$nor: [{a: 1}, {b: 2}]}, {a: [1, 2, 3]}); + match({$nor: [{a: 1}, {b: 2}]}, {c: [1, 2, 3]}); + match({$nor: [{a: 1}, {b: 2}]}, {a: [2, 3, 4]}); + nomatch({$nor: [{a: 1}, {a: 2}]}, {a: 1}); + + // $nor and $lt, $lte, $gt, $gte + nomatch({$nor: [{a: {$lte: 1}}, {a: 2}]}, {a: 1}); + match({$nor: [{a: {$lt: 1}}, {a: 2}]}, {a: 1}); + nomatch({$nor: [{a: {$gte: 1}}, {a: 2}]}, {a: 1}); + match({$nor: [{a: {$gt: 1}}, {a: 2}]}, {a: 1}); + nomatch({$nor: [{b: {$gt: 1}}, {b: {$lt: 3}}]}, {b: 2}); + match({$nor: [{b: {$lt: 1}}, {b: {$gt: 3}}]}, {b: 2}); + + // $nor and $in + nomatch({$nor: [{a: {$in: [1, 2, 3]}}]}, {a: 1}); + match({$nor: [{a: {$in: [4, 5, 6]}}]}, {a: 1}); + nomatch({$nor: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {a: 1}); + nomatch({$nor: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {b: 2}); + match({$nor: [{a: {$in: [1, 2, 3]}}, {b: 2}]}, {c: 3}); + nomatch({$nor: [{a: {$in: [1, 2, 3]}}, {b: {$in: [1, 2, 3]}}]}, {b: 2}); + match({$nor: [{a: {$in: [1, 2, 3]}}, {b: {$in: [4, 5, 6]}}]}, {b: 2}); + + // $nor and $nin + match({$nor: [{a: {$nin: [1, 2, 3]}}]}, {a: 1}); + nomatch({$nor: [{a: {$nin: [4, 5, 6]}}]}, {a: 1}); + match({$nor: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {a: 1}); + nomatch({$nor: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {b: 2}); + nomatch({$nor: [{a: {$nin: [1, 2, 3]}}, {b: 2}]}, {c: 3}); + nomatch({$nor: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {b: 2}); + match({$nor: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {a: 1, b: 2}); + nomatch({$nor: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [4, 5, 6]}}]}, {b: 2}); + + // $nor and dot-notation + nomatch({$nor: [{'a.b': 1}, {'a.b': 2}]}, {a: {b: 1}}); + nomatch({$nor: [{'a.b': 1}, {'a.c': 1}]}, {a: {b: 1}}); + match({$nor: [{'a.b': 2}, {'a.c': 1}]}, {a: {b: 1}}); + + // $nor and nested objects + nomatch({$nor: [{a: {b: 1, c: 2}}, {a: {b: 2, c: 1}}]}, {a: {b: 1, c: 2}}); + match({$nor: [{a: {b: 1, c: 3}}, {a: {b: 2, c: 1}}]}, {a: {b: 1, c: 2}}); + + // $nor and regexes + nomatch({$nor: [{a: /a/}]}, {a: 'cat'}); + match({$nor: [{a: /o/}]}, {a: 'cat'}); + nomatch({$nor: [{a: /a/}, {a: /o/}]}, {a: 'cat'}); + match({$nor: [{a: /i/}, {a: /o/}]}, {a: 'cat'}); + nomatch({$nor: [{a: /i/}, {b: /o/}]}, {a: 'cat', b: 'dog'}); + + // $nor and $ne + nomatch({$nor: [{a: {$ne: 1}}]}, {}); + match({$nor: [{a: {$ne: 1}}]}, {a: 1}); + nomatch({$nor: [{a: {$ne: 1}}]}, {a: 2}); + nomatch({$nor: [{a: {$ne: 1}}]}, {b: 1}); + nomatch({$nor: [{a: {$ne: 1}}, {a: {$ne: 2}}]}, {a: 1}); + nomatch({$nor: [{a: {$ne: 1}}, {b: {$ne: 1}}]}, {a: 1}); + match({$nor: [{a: {$ne: 1}}, {b: {$ne: 2}}]}, {a: 1, b: 2}); + + // $nor and $not + nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}]}, {}); + match({$nor: [{a: {$not: {$mod: [10, 1]}}}]}, {a: 1}); + nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}]}, {a: 2}); + nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}, {a: {$not: {$mod: [10, 2]}}}]}, {a: 1}); + match({$nor: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 1}); + nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 2}); + nomatch({$nor: [{a: {$not: {$mod: [10, 1]}}}, {a: {$mod: [10, 2]}}]}, {a: 3}); + + // $and + + test.throws(() => { + match({$and: []}, {}); + }); + test.throws(() => { + match({$and: [5]}, {}); + }); + test.throws(() => { + match({$and: []}, {a: 1}); + }); + match({$and: [{a: 1}]}, {a: 1}); + nomatch({$and: [{a: 1}, {a: 2}]}, {a: 1}); + nomatch({$and: [{a: 1}, {b: 1}]}, {a: 1}); + match({$and: [{a: 1}, {b: 2}]}, {a: 1, b: 2}); + nomatch({$and: [{a: 1}, {b: 1}]}, {a: 1, b: 2}); + match({$and: [{a: 1}, {b: 2}], c: 3}, {a: 1, b: 2, c: 3}); + nomatch({$and: [{a: 1}, {b: 2}], c: 4}, {a: 1, b: 2, c: 3}); + + // $and and regexes + match({$and: [{a: /a/}]}, {a: 'cat'}); + match({$and: [{a: /a/i}]}, {a: 'CAT'}); + nomatch({$and: [{a: /o/}]}, {a: 'cat'}); + nomatch({$and: [{a: /a/}, {a: /o/}]}, {a: 'cat'}); + match({$and: [{a: /a/}, {b: /o/}]}, {a: 'cat', b: 'dog'}); + nomatch({$and: [{a: /a/}, {b: /a/}]}, {a: 'cat', b: 'dog'}); + + // $and, dot-notation, and nested objects + match({$and: [{'a.b': 1}]}, {a: {b: 1}}); + match({$and: [{a: {b: 1}}]}, {a: {b: 1}}); + nomatch({$and: [{'a.b': 2}]}, {a: {b: 1}}); + nomatch({$and: [{'a.c': 1}]}, {a: {b: 1}}); + nomatch({$and: [{'a.b': 1}, {'a.b': 2}]}, {a: {b: 1}}); + nomatch({$and: [{'a.b': 1}, {a: {b: 2}}]}, {a: {b: 1}}); + match({$and: [{'a.b': 1}, {'c.d': 2}]}, {a: {b: 1}, c: {d: 2}}); + nomatch({$and: [{'a.b': 1}, {'c.d': 1}]}, {a: {b: 1}, c: {d: 2}}); + match({$and: [{'a.b': 1}, {c: {d: 2}}]}, {a: {b: 1}, c: {d: 2}}); + nomatch({$and: [{'a.b': 1}, {c: {d: 1}}]}, {a: {b: 1}, c: {d: 2}}); + nomatch({$and: [{'a.b': 2}, {c: {d: 2}}]}, {a: {b: 1}, c: {d: 2}}); + match({$and: [{a: {b: 1}}, {c: {d: 2}}]}, {a: {b: 1}, c: {d: 2}}); + nomatch({$and: [{a: {b: 2}}, {c: {d: 2}}]}, {a: {b: 1}, c: {d: 2}}); + + // $and and $in + nomatch({$and: [{a: {$in: []}}]}, {}); + match({$and: [{a: {$in: [1, 2, 3]}}]}, {a: 1}); + nomatch({$and: [{a: {$in: [4, 5, 6]}}]}, {a: 1}); + nomatch({$and: [{a: {$in: [1, 2, 3]}}, {a: {$in: [4, 5, 6]}}]}, {a: 1}); + nomatch({$and: [{a: {$in: [1, 2, 3]}}, {b: {$in: [1, 2, 3]}}]}, {a: 1, b: 4}); + match({$and: [{a: {$in: [1, 2, 3]}}, {b: {$in: [4, 5, 6]}}]}, {a: 1, b: 4}); + + + // $and and $nin + match({$and: [{a: {$nin: []}}]}, {}); + nomatch({$and: [{a: {$nin: [1, 2, 3]}}]}, {a: 1}); + match({$and: [{a: {$nin: [4, 5, 6]}}]}, {a: 1}); + nomatch({$and: [{a: {$nin: [1, 2, 3]}}, {a: {$nin: [4, 5, 6]}}]}, {a: 1}); + nomatch({$and: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [1, 2, 3]}}]}, {a: 1, b: 4}); + nomatch({$and: [{a: {$nin: [1, 2, 3]}}, {b: {$nin: [4, 5, 6]}}]}, {a: 1, b: 4}); + + // $and and $lt, $lte, $gt, $gte + match({$and: [{a: {$lt: 2}}]}, {a: 1}); + nomatch({$and: [{a: {$lt: 1}}]}, {a: 1}); + match({$and: [{a: {$lte: 1}}]}, {a: 1}); + match({$and: [{a: {$gt: 0}}]}, {a: 1}); + nomatch({$and: [{a: {$gt: 1}}]}, {a: 1}); + match({$and: [{a: {$gte: 1}}]}, {a: 1}); + match({$and: [{a: {$gt: 0}}, {a: {$lt: 2}}]}, {a: 1}); + nomatch({$and: [{a: {$gt: 1}}, {a: {$lt: 2}}]}, {a: 1}); + nomatch({$and: [{a: {$gt: 0}}, {a: {$lt: 1}}]}, {a: 1}); + match({$and: [{a: {$gte: 1}}, {a: {$lte: 1}}]}, {a: 1}); + nomatch({$and: [{a: {$gte: 2}}, {a: {$lte: 0}}]}, {a: 1}); + + // $and and $ne + match({$and: [{a: {$ne: 1}}]}, {}); + nomatch({$and: [{a: {$ne: 1}}]}, {a: 1}); + match({$and: [{a: {$ne: 1}}]}, {a: 2}); + nomatch({$and: [{a: {$ne: 1}}, {a: {$ne: 2}}]}, {a: 2}); + match({$and: [{a: {$ne: 1}}, {a: {$ne: 3}}]}, {a: 2}); + + // $and and $not + match({$and: [{a: {$not: {$gt: 2}}}]}, {a: 1}); + nomatch({$and: [{a: {$not: {$lt: 2}}}]}, {a: 1}); + match({$and: [{a: {$not: {$lt: 0}}}, {a: {$not: {$gt: 2}}}]}, {a: 1}); + nomatch({$and: [{a: {$not: {$lt: 2}}}, {a: {$not: {$gt: 0}}}]}, {a: 1}); + + // $where + match({$where: 'this.a === 1'}, {a: 1}); + match({$where: 'obj.a === 1'}, {a: 1}); + nomatch({$where: 'this.a !== 1'}, {a: 1}); + nomatch({$where: 'obj.a !== 1'}, {a: 1}); + nomatch({$where: 'this.a === 1', a: 2}, {a: 1}); + match({$where: 'this.a === 1', b: 2}, {a: 1, b: 2}); + match({$where: 'this.a === 1 && this.b === 2'}, {a: 1, b: 2}); + match({$where: 'this.a instanceof Array'}, {a: []}); + nomatch({$where: 'this.a instanceof Array'}, {a: 1}); + + // reaching into array + match({'dogs.0.name': 'Fido'}, {dogs: [{name: 'Fido'}, {name: 'Rex'}]}); + match({'dogs.1.name': 'Rex'}, {dogs: [{name: 'Fido'}, {name: 'Rex'}]}); + nomatch({'dogs.1.name': 'Fido'}, {dogs: [{name: 'Fido'}, {name: 'Rex'}]}); + match({'room.1b': 'bla'}, {room: {'1b': 'bla'}}); + + match({'dogs.name': 'Fido'}, {dogs: [{name: 'Fido'}, {name: 'Rex'}]}); + match({'dogs.name': 'Rex'}, {dogs: [{name: 'Fido'}, {name: 'Rex'}]}); + match({'animals.dogs.name': 'Fido'}, + {animals: [{dogs: [{name: 'Rover'}]}, + {}, + {dogs: [{name: 'Fido'}, {name: 'Rex'}]}]}); + match({'animals.dogs.name': 'Fido'}, + {animals: [{dogs: {name: 'Rex'}}, + {dogs: {name: 'Fido'}}]}); + match({'animals.dogs.name': 'Fido'}, + {animals: [{dogs: [{name: 'Rover'}]}, + {}, + {dogs: [{name: ['Fido']}, {name: 'Rex'}]}]}); + nomatch({'dogs.name': 'Fido'}, {dogs: []}); + + // $elemMatch + match({dogs: {$elemMatch: {name: /e/}}}, + {dogs: [{name: 'Fido'}, {name: 'Rex'}]}); + nomatch({dogs: {$elemMatch: {name: /a/}}}, + {dogs: [{name: 'Fido'}, {name: 'Rex'}]}); + match({dogs: {$elemMatch: {age: {$gt: 4}}}}, + {dogs: [{name: 'Fido', age: 5}, {name: 'Rex', age: 3}]}); + match({dogs: {$elemMatch: {name: 'Fido', age: {$gt: 4}}}}, + {dogs: [{name: 'Fido', age: 5}, {name: 'Rex', age: 3}]}); + nomatch({dogs: {$elemMatch: {name: 'Fido', age: {$gt: 5}}}}, + {dogs: [{name: 'Fido', age: 5}, {name: 'Rex', age: 3}]}); + match({dogs: {$elemMatch: {name: /i/, age: {$gt: 4}}}}, + {dogs: [{name: 'Fido', age: 5}, {name: 'Rex', age: 3}]}); + nomatch({dogs: {$elemMatch: {name: /e/, age: 5}}}, + {dogs: [{name: 'Fido', age: 5}, {name: 'Rex', age: 3}]}); + match({x: {$elemMatch: {y: 9}}}, {x: [{y: 9}]}); + nomatch({x: {$elemMatch: {y: 9}}}, {x: [[{y: 9}]]}); + match({x: {$elemMatch: {$gt: 5, $lt: 9}}}, {x: [8]}); + nomatch({x: {$elemMatch: {$gt: 5, $lt: 9}}}, {x: [[8]]}); + match({'a.x': {$elemMatch: {y: 9}}}, + {a: [{x: []}, {x: [{y: 9}]}]}); + nomatch({a: {$elemMatch: {x: 5}}}, {a: {x: 5}}); + match({a: {$elemMatch: {0: {$gt: 5, $lt: 9}}}}, {a: [[6]]}); + match({a: {$elemMatch: {'0.b': {$gt: 5, $lt: 9}}}}, {a: [[{b: 6}]]}); + match({a: {$elemMatch: {x: 1, $or: [{a: 1}, {b: 1}]}}}, + {a: [{x: 1, b: 1}]}); + match({a: {$elemMatch: {$or: [{a: 1}, {b: 1}], x: 1}}}, + {a: [{x: 1, b: 1}]}); + match({a: {$elemMatch: {$or: [{a: 1}, {b: 1}]}}}, + {a: [{x: 1, b: 1}]}); + match({a: {$elemMatch: {$or: [{a: 1}, {b: 1}]}}}, + {a: [{x: 1, b: 1}]}); + match({a: {$elemMatch: {$and: [{b: 1}, {x: 1}]}}}, + {a: [{x: 1, b: 1}]}); + nomatch({a: {$elemMatch: {x: 1, $or: [{a: 1}, {b: 1}]}}}, + {a: [{b: 1}]}); + nomatch({a: {$elemMatch: {x: 1, $or: [{a: 1}, {b: 1}]}}}, + {a: [{x: 1}]}); + nomatch({a: {$elemMatch: {x: 1, $or: [{a: 1}, {b: 1}]}}}, + {a: [{x: 1}, {b: 1}]}); + + test.throws(() => { + match({a: {$elemMatch: {$gte: 1, $or: [{a: 1}, {b: 1}]}}}, + {a: [{x: 1, b: 1}]}); + }); + + test.throws(() => { + match({x: {$elemMatch: {$and: [{$gt: 5, $lt: 9}]}}}, {x: [8]}); + }); + + // $comment + match({a: 5, $comment: 'asdf'}, {a: 5}); + nomatch({a: 6, $comment: 'asdf'}, {a: 5}); + + // XXX still needs tests: + // - non-scalar arguments to $gt, $lt, etc +}); + +Tinytest.add('minimongo - projection_compiler', test => { + const testProjection = (projection, tests) => { + const projection_f = LocalCollection._compileProjection(projection); + const equalNonStrict = (a, b, desc) => { + test.isTrue(EJSON.equals(a, b), desc); + }; + + tests.forEach(testCase => { + equalNonStrict(projection_f(testCase[0]), testCase[1], testCase[2]); + }); + }; + + const testCompileProjectionThrows = (projection, expectedError) => { + test.throws(() => { + LocalCollection._compileProjection(projection); + }, expectedError); + }; + + testProjection({ foo: 1, bar: 1 }, [ + [{ foo: 42, bar: 'something', baz: 'else' }, + { foo: 42, bar: 'something' }, + 'simplest - whitelist'], + + [{ foo: { nested: 17 }, baz: {} }, + { foo: { nested: 17 } }, + 'nested whitelisted field'], + + [{ _id: 'uid', bazbaz: 42 }, + { _id: 'uid' }, + 'simplest whitelist - preserve _id'], + ]); + + testProjection({ foo: 0, bar: 0 }, [ + [{ foo: 42, bar: 'something', baz: 'else' }, + { baz: 'else' }, + 'simplest - blacklist'], + + [{ foo: { nested: 17 }, baz: { foo: 'something' } }, + { baz: { foo: 'something' } }, + 'nested blacklisted field'], + + [{ _id: 'uid', bazbaz: 42 }, + { _id: 'uid', bazbaz: 42 }, + 'simplest blacklist - preserve _id'], + ]); + + testProjection({ _id: 0, foo: 1 }, [ + [{ foo: 42, bar: 33, _id: 'uid' }, + { foo: 42 }, + 'whitelist - _id blacklisted'], + ]); + + testProjection({ _id: 0, foo: 0 }, [ + [{ foo: 42, bar: 33, _id: 'uid' }, + { bar: 33 }, + 'blacklist - _id blacklisted'], + ]); + + testProjection({ 'foo.bar.baz': 1 }, [ + [{ foo: { meh: 'fur', bar: { baz: 42 }, tr: 1 }, bar: 33, baz: 'trolololo' }, + { foo: { bar: { baz: 42 } } }, + 'whitelist nested'], + + // Behavior of this test is looked up in actual mongo + [{ foo: { meh: 'fur', bar: 'nope', tr: 1 }, bar: 33, baz: 'trolololo' }, + { foo: {} }, + 'whitelist nested - path not found in doc, different type'], + + // Behavior of this test is looked up in actual mongo + [{ foo: { meh: 'fur', bar: [], tr: 1 }, bar: 33, baz: 'trolololo' }, + { foo: { bar: [] } }, + 'whitelist nested - path not found in doc'], + ]); + + testProjection({ 'hope.humanity': 0, 'hope.people': 0 }, [ + [{ hope: { humanity: 'lost', people: 'broken', candies: 'long live!' } }, + { hope: { candies: 'long live!' } }, + 'blacklist nested'], + + [{ hope: 'new' }, + { hope: 'new' }, + 'blacklist nested - path not found in doc'], + ]); + + testProjection({ _id: 1 }, [ + [{ _id: 42, x: 1, y: { z: '2' } }, + { _id: 42 }, + '_id whitelisted'], + [{ _id: 33 }, + { _id: 33 }, + '_id whitelisted, _id only'], + [{ x: 1 }, + {}, + '_id whitelisted, no _id'], + ]); + + testProjection({ _id: 0 }, [ + [{ _id: 42, x: 1, y: { z: '2' } }, + { x: 1, y: { z: '2' } }, + '_id blacklisted'], + [{ _id: 33 }, + {}, + '_id blacklisted, _id only'], + [{ x: 1 }, + { x: 1 }, + '_id blacklisted, no _id'], + ]); + + testProjection({}, [ + [{ a: 1, b: 2, c: '3' }, + { a: 1, b: 2, c: '3' }, + 'empty projection'], + ]); + + testCompileProjectionThrows( + { inc: 1, excl: 0 }, + 'You cannot currently mix including and excluding fields'); + testCompileProjectionThrows( + { _id: 1, a: 0 }, + 'You cannot currently mix including and excluding fields'); + + testCompileProjectionThrows( + { a: 1, 'a.b': 1 }, + 'using both of them may trigger unexpected behavior'); + testCompileProjectionThrows( + { 'a.b.c': 1, 'a.b': 1, a: 1 }, + 'using both of them may trigger unexpected behavior'); + + testCompileProjectionThrows('some string', 'fields option must be an object'); +}); + +Tinytest.add('minimongo - fetch with fields', test => { + const c = new LocalCollection(); + Array.from({length: 30}, (x, i) => { + c.insert({ + something: Random.id(), + anything: { + foo: 'bar', + cool: 'hot', + }, + nothing: i, + i, + }); + }); + + // Test just a regular fetch with some projection + let fetchResults = c.find({}, { fields: { + something: 1, + 'anything.foo': 1, + } }).fetch(); + + test.isTrue(fetchResults.every(x => x && + x.something && + x.anything && + x.anything.foo && + x.anything.foo === 'bar' && + !hasOwn.call(x, 'nothing') && + !hasOwn.call(x.anything, 'cool'))); + + // Test with a selector, even field used in the selector is excluded in the + // projection + fetchResults = c.find({ + nothing: { $gte: 5 }, + }, { + fields: { nothing: 0 }, + }).fetch(); + + test.isTrue(fetchResults.every(x => x && + x.something && + x.anything && + x.anything.foo === 'bar' && + x.anything.cool === 'hot' && + !hasOwn.call(x, 'nothing') && + x.i && + x.i >= 5)); + + test.isTrue(fetchResults.length === 25); + + // Test that we can sort, based on field excluded from the projection, use + // skip and limit as well! + // following find will get indexes [10..20) sorted by nothing + fetchResults = c.find({}, { + sort: { + nothing: 1, + }, + limit: 10, + skip: 10, + fields: { + i: 1, + something: 1, + }, + }).fetch(); + + test.isTrue(fetchResults.every(x => x && + x.something && + x.i >= 10 && x.i < 20)); + + fetchResults.forEach((x, i, arr) => { + if (!i) return; + test.isTrue(x.i === arr[i - 1].i + 1); + }); + + // Temporary unsupported operators + // queries are taken from MongoDB docs examples + test.throws(() => { + c.find({}, { fields: { 'grades.$': 1 } }); + }); + test.throws(() => { + c.find({}, { fields: { grades: { $elemMatch: { mean: 70 } } } }); + }); + test.throws(() => { + c.find({}, { fields: { grades: { $slice: [20, 10] } } }); + }); +}); + +Tinytest.add('minimongo - fetch with projection, subarrays', test => { + // Apparently projection of type 'foo.bar.x' for + // { foo: [ { bar: { x: 42 } }, { bar: { x: 3 } } ] } + // should return exactly this object. More precisely, arrays are considered as + // sets and are queried separately and then merged back to result set + const c = new LocalCollection(); + + // Insert a test object with two set fields + c.insert({ + setA: [{ + fieldA: 42, + fieldB: 33, + }, { + fieldA: 'the good', + fieldB: 'the bad', + fieldC: 'the ugly', + }], + setB: [{ + anotherA: { }, + anotherB: 'meh', + }, { + anotherA: 1234, + anotherB: 431, + }], + }); + + const equalNonStrict = (a, b, desc) => { + test.isTrue(EJSON.equals(a, b), desc); + }; + + const testForProjection = (projection, expected) => { + const fetched = c.find({}, { fields: projection }).fetch()[0]; + equalNonStrict(fetched, expected, `failed sub-set projection: ${JSON.stringify(projection)}`); + }; + + testForProjection({ 'setA.fieldA': 1, 'setB.anotherB': 1, _id: 0 }, + { + setA: [{ fieldA: 42 }, { fieldA: 'the good' }], + setB: [{ anotherB: 'meh' }, { anotherB: 431 }], + }); + + testForProjection({ 'setA.fieldA': 0, 'setB.anotherA': 0, _id: 0 }, + { + setA: [{fieldB: 33}, {fieldB: 'the bad', fieldC: 'the ugly'}], + setB: [{ anotherB: 'meh' }, { anotherB: 431 }], + }); + + c.remove({}); + c.insert({a: [[{b: 1, c: 2}, {b: 2, c: 4}], {b: 3, c: 5}, [{b: 4, c: 9}]]}); + + testForProjection({ 'a.b': 1, _id: 0 }, + {a: [ [ { b: 1 }, { b: 2 } ], { b: 3 }, [ { b: 4 } ] ] }); + testForProjection({ 'a.b': 0, _id: 0 }, + {a: [ [ { c: 2 }, { c: 4 } ], { c: 5 }, [ { c: 9 } ] ] }); +}); + +Tinytest.add('minimongo - fetch with projection, deep copy', test => { + // Compiled fields projection defines the contract: returned document doesn't + // retain anything from the passed argument. + const doc = { + a: { x: 42 }, + b: { + y: { z: 33 }, + }, + c: 'asdf', + }; + + let fields = { + a: 1, + 'b.y': 1, + }; + + let projectionFn = LocalCollection._compileProjection(fields); + let filteredDoc = projectionFn(doc); + doc.a.x++; + doc.b.y.z--; + test.equal(filteredDoc.a.x, 42, 'projection returning deep copy - including'); + test.equal(filteredDoc.b.y.z, 33, 'projection returning deep copy - including'); + + fields = { c: 0 }; + projectionFn = LocalCollection._compileProjection(fields); + filteredDoc = projectionFn(doc); + + doc.a.x = 5; + test.equal(filteredDoc.a.x, 43, 'projection returning deep copy - excluding'); +}); + +Tinytest.add('minimongo - observe ordered with projection', test => { + // These tests are copy-paste from "minimongo -observe ordered", + // slightly modified to test projection + const operations = []; + const cbs = log_callbacks(operations); + let handle; + + const c = new LocalCollection(); + handle = c.find({}, {sort: {a: 1}, fields: { a: 1 }}).observe(cbs); + test.isTrue(handle.collection === c); + + c.insert({_id: 'foo', a: 1, b: 2}); + test.equal(operations.shift(), ['added', {a: 1}, 0, null]); + c.update({a: 1}, {$set: {a: 2, b: 1}}); + test.equal(operations.shift(), ['changed', {a: 2}, 0, {a: 1}]); + c.insert({_id: 'bar', a: 10, c: 33}); + test.equal(operations.shift(), ['added', {a: 10}, 1, null]); + c.update({}, {$inc: {a: 1}}, {multi: true}); + c.update({}, {$inc: {c: 1}}, {multi: true}); + test.equal(operations.shift(), ['changed', {a: 3}, 0, {a: 2}]); + test.equal(operations.shift(), ['changed', {a: 11}, 1, {a: 10}]); + c.update({a: 11}, {a: 1, b: 44}); + test.equal(operations.shift(), ['changed', {a: 1}, 1, {a: 11}]); + test.equal(operations.shift(), ['moved', {a: 1}, 1, 0, 'foo']); + c.remove({a: 2}); + test.equal(operations.shift(), undefined); + c.remove({a: 3}); + test.equal(operations.shift(), ['removed', 'foo', 1, {a: 3}]); + + // test stop + handle.stop(); + const idA2 = Random.id(); + c.insert({_id: idA2, a: 2}); + test.equal(operations.shift(), undefined); + + const cursor = c.find({}, {fields: {a: 1, _id: 0}}); + test.throws(() => { + cursor.observeChanges({added() {}}); + }); + test.throws(() => { + cursor.observe({added() {}}); + }); + + // test initial inserts (and backwards sort) + handle = c.find({}, {sort: {a: -1}, fields: { a: 1 } }).observe(cbs); + test.equal(operations.shift(), ['added', {a: 2}, 0, null]); + test.equal(operations.shift(), ['added', {a: 1}, 1, null]); + handle.stop(); + + // test _suppress_initial + handle = c.find({}, {sort: {a: -1}, fields: { a: 1 }}).observe(Object.assign(cbs, {_suppress_initial: true})); + test.equal(operations.shift(), undefined); + c.insert({a: 100, b: { foo: 'bar' }}); + test.equal(operations.shift(), ['added', {a: 100}, 0, idA2]); + handle.stop(); + + // test skip and limit. + c.remove({}); + handle = c.find({}, {sort: {a: 1}, skip: 1, limit: 2, fields: { blacklisted: 0 }}).observe(cbs); + test.equal(operations.shift(), undefined); + c.insert({a: 1, blacklisted: 1324}); + test.equal(operations.shift(), undefined); + c.insert({_id: 'foo', a: 2, blacklisted: ['something']}); + test.equal(operations.shift(), ['added', {a: 2}, 0, null]); + c.insert({a: 3, blacklisted: { 2: 3 }}); + test.equal(operations.shift(), ['added', {a: 3}, 1, null]); + c.insert({a: 4, blacklisted: 6}); + test.equal(operations.shift(), undefined); + c.update({a: 1}, {a: 0, blacklisted: 4444}); + test.equal(operations.shift(), undefined); + c.update({a: 0}, {a: 5, blacklisted: 11111}); + test.equal(operations.shift(), ['removed', 'foo', 0, {a: 2}]); + test.equal(operations.shift(), ['added', {a: 4}, 1, null]); + c.update({a: 3}, {a: 3.5, blacklisted: 333.4444}); + test.equal(operations.shift(), ['changed', {a: 3.5}, 0, {a: 3}]); + handle.stop(); + + // test _no_indices + + c.remove({}); + handle = c.find({}, {sort: {a: 1}, fields: { a: 1 }}).observe(Object.assign(cbs, {_no_indices: true})); + c.insert({_id: 'foo', a: 1, zoo: 'crazy'}); + test.equal(operations.shift(), ['added', {a: 1}, -1, null]); + c.update({a: 1}, {$set: {a: 2, foobar: 'player'}}); + test.equal(operations.shift(), ['changed', {a: 2}, -1, {a: 1}]); + c.insert({a: 10, b: 123.45}); + test.equal(operations.shift(), ['added', {a: 10}, -1, null]); + c.update({}, {$inc: {a: 1, b: 2}}, {multi: true}); + test.equal(operations.shift(), ['changed', {a: 3}, -1, {a: 2}]); + test.equal(operations.shift(), ['changed', {a: 11}, -1, {a: 10}]); + c.update({a: 11, b: 125.45}, {a: 1, b: 444}); + test.equal(operations.shift(), ['changed', {a: 1}, -1, {a: 11}]); + test.equal(operations.shift(), ['moved', {a: 1}, -1, -1, 'foo']); + c.remove({a: 2}); + test.equal(operations.shift(), undefined); + c.remove({a: 3}); + test.equal(operations.shift(), ['removed', 'foo', -1, {a: 3}]); + handle.stop(); +}); + + +Tinytest.add('minimongo - ordering', test => { + const shortBinary = EJSON.newBinary(1); + shortBinary[0] = 128; + const longBinary1 = EJSON.newBinary(2); + longBinary1[1] = 42; + const longBinary2 = EJSON.newBinary(2); + longBinary2[1] = 50; + + const date1 = new Date; + const date2 = new Date(date1.getTime() + 1000); + + // value ordering + assert_ordering(test, LocalCollection._f._cmp, [ + null, + 1, 2.2, 3, + '03', '1', '11', '2', 'a', 'aaa', + {}, {a: 2}, {a: 3}, {a: 3, b: 4}, {b: 4}, {b: 4, a: 3}, + {b: {}}, {b: [1, 2, 3]}, {b: [1, 2, 4]}, + [], [1, 2], [1, 2, 3], [1, 2, 4], [1, 2, '4'], [1, 2, [4]], + shortBinary, longBinary1, longBinary2, + new MongoID.ObjectID('1234567890abcd1234567890'), + new MongoID.ObjectID('abcd1234567890abcd123456'), + false, true, + date1, date2, + ]); + + // document ordering under a sort specification + const verify = (sorts, docs) => { + (Array.isArray(sorts) ? sorts : [sorts]).forEach(sort => { + const sorter = new Minimongo.Sorter(sort); + assert_ordering(test, sorter.getComparator(), docs); + }); + }; + + // note: [] doesn't sort with "arrays", it sorts as "undefined". the position + // of arrays in _typeorder only matters for things like $lt. (This behavior + // verified with MongoDB 2.2.1.) We don't define the relative order of {a: []} + // and {c: 1} is undefined (MongoDB does seem to care but it's not clear how + // or why). + verify([{a: 1}, ['a'], [['a', 'asc']]], + [{a: []}, {a: 1}, {a: {}}, {a: true}]); + verify([{a: 1}, ['a'], [['a', 'asc']]], + [{c: 1}, {a: 1}, {a: {}}, {a: true}]); + verify([{a: -1}, [['a', 'desc']]], + [{a: true}, {a: {}}, {a: 1}, {c: 1}]); + verify([{a: -1}, [['a', 'desc']]], + [{a: true}, {a: {}}, {a: 1}, {a: []}]); + + verify([{a: 1, b: -1}, ['a', ['b', 'desc']], + [['a', 'asc'], ['b', 'desc']]], + [{c: 1}, {a: 1, b: 3}, {a: 1, b: 2}, {a: 2, b: 0}]); + + verify([{a: 1, b: 1}, ['a', 'b'], + [['a', 'asc'], ['b', 'asc']]], + [{c: 1}, {a: 1, b: 2}, {a: 1, b: 3}, {a: 2, b: 0}]); + + test.throws(() => { + new Minimongo.Sorter('a'); + }); + + test.throws(() => { + new Minimongo.Sorter(123); + }); + + // We don't support $natural:1 (since we don't actually have Mongo's on-disk + // ordering available!) + test.throws(() => { + new Minimongo.Sorter({$natural: 1}); + }); + + // No sort spec implies everything equal. + test.equal(new Minimongo.Sorter({}).getComparator()({a: 1}, {a: 2}), 0); + + // All sorts of array edge cases! + // Increasing sort sorts by the smallest element it finds; 1 < 2. + verify({a: 1}, [ + {a: [1, 10, 20]}, + {a: [5, 2, 99]}, + ]); + // Decreasing sorts by largest it finds; 99 > 20. + verify({a: -1}, [ + {a: [5, 2, 99]}, + {a: [1, 10, 20]}, + ]); + // Can also sort by specific array indices. + verify({'a.1': 1}, [ + {a: [5, 2, 99]}, + {a: [1, 10, 20]}, + ]); + // We do NOT expand sub-arrays, so the minimum in the second doc is 5, not + // -20. (Numbers always sort before arrays.) + verify({a: 1}, [ + {a: [1, [10, 15], 20]}, + {a: [5, [-5, -20], 18]}, + ]); + // The maximum in each of these is the array, since arrays are "greater" than + // numbers. And [10, 15] is greater than [-5, -20]. + verify({a: -1}, [ + {a: [1, [10, 15], 20]}, + {a: [5, [-5, -20], 18]}, + ]); + // 'a.0' here ONLY means "first element of a", not "first element of something + // found in a", so it CANNOT find the 10 or -5. + verify({'a.0': 1}, [ + {a: [1, [10, 15], 20]}, + {a: [5, [-5, -20], 18]}, + ]); + verify({'a.0': -1}, [ + {a: [5, [-5, -20], 18]}, + {a: [1, [10, 15], 20]}, + ]); + // Similarly, this is just comparing [-5,-20] to [10, 15]. + verify({'a.1': 1}, [ + {a: [5, [-5, -20], 18]}, + {a: [1, [10, 15], 20]}, + ]); + verify({'a.1': -1}, [ + {a: [1, [10, 15], 20]}, + {a: [5, [-5, -20], 18]}, + ]); + // Here we are just comparing [10,15] directly to [19,3] (and NOT also + // iterating over the numbers; this is implemented by setting dontIterate in + // makeLookupFunction). So [10,15]<[19,3] even though 3 is the smallest + // number you can find there. + verify({'a.1': 1}, [ + {a: [1, [10, 15], 20]}, + {a: [5, [19, 3], 18]}, + ]); + verify({'a.1': -1}, [ + {a: [5, [19, 3], 18]}, + {a: [1, [10, 15], 20]}, + ]); + // Minimal elements are 1 and 5. + verify({a: 1}, [ + {a: [1, [10, 15], 20]}, + {a: [5, [19, 3], 18]}, + ]); + // Maximal elements are [19,3] and [10,15] (because arrays sort higher than + // numbers), even though there's a 20 floating around. + verify({a: -1}, [ + {a: [5, [19, 3], 18]}, + {a: [1, [10, 15], 20]}, + ]); + // Maximal elements are [10,15] and [3,19]. [10,15] is bigger even though 19 + // is the biggest number in them, because array comparison is lexicographic. + verify({a: -1}, [ + {a: [1, [10, 15], 20]}, + {a: [5, [3, 19], 18]}, + ]); + + // (0,4) < (0,5), so they go in this order. It's not correct to consider + // (0,3) as a sort key for the second document because they come from + // different a-branches. + verify({'a.x': 1, 'a.y': 1}, [ + {a: [{x: 0, y: 4}]}, + {a: [{x: 0, y: 5}, {x: 1, y: 3}]}, + ]); + + verify({'a.0.s': 1}, [ + {a: [ {s: 1} ]}, + {a: [ {s: 2} ]}, + ]); +}); + +Tinytest.add('minimongo - sort', test => { + const c = new LocalCollection(); + for (let i = 0; i < 50; i++) { + for (let j = 0; j < 2; j++) {c.insert({a: i, b: j, _id: `${i}_${j}`});} + } + + test.equal( + c.find({a: {$gt: 10}}, {sort: {b: -1, a: 1}, limit: 5}).fetch(), [ + {a: 11, b: 1, _id: '11_1'}, + {a: 12, b: 1, _id: '12_1'}, + {a: 13, b: 1, _id: '13_1'}, + {a: 14, b: 1, _id: '14_1'}, + {a: 15, b: 1, _id: '15_1'}]); + + test.equal( + c.find({a: {$gt: 10}}, {sort: {b: -1, a: 1}, skip: 3, limit: 5}).fetch(), [ + {a: 14, b: 1, _id: '14_1'}, + {a: 15, b: 1, _id: '15_1'}, + {a: 16, b: 1, _id: '16_1'}, + {a: 17, b: 1, _id: '17_1'}, + {a: 18, b: 1, _id: '18_1'}]); + + test.equal( + c.find({a: {$gte: 20}}, {sort: {a: 1, b: -1}, skip: 50, limit: 5}).fetch(), [ + {a: 45, b: 1, _id: '45_1'}, + {a: 45, b: 0, _id: '45_0'}, + {a: 46, b: 1, _id: '46_1'}, + {a: 46, b: 0, _id: '46_0'}, + {a: 47, b: 1, _id: '47_1'}]); +}); + +Tinytest.add('minimongo - subkey sort', test => { + const c = new LocalCollection(); + + // normal case + c.insert({a: {b: 2}}); + c.insert({a: {b: 1}}); + c.insert({a: {b: 3}}); + test.equal( + c.find({}, {sort: {'a.b': -1}}).fetch().map(doc => doc.a), + [{b: 3}, {b: 2}, {b: 1}]); + + // isn't an object + c.insert({a: 1}); + test.equal( + c.find({}, {sort: {'a.b': 1}}).fetch().map(doc => doc.a), + [1, {b: 1}, {b: 2}, {b: 3}]); + + // complex object + c.insert({a: {b: {c: 1}}}); + test.equal( + c.find({}, {sort: {'a.b': -1}}).fetch().map(doc => doc.a), + [{b: {c: 1}}, {b: 3}, {b: 2}, {b: 1}, 1]); + + // no such top level prop + c.insert({c: 1}); + test.equal( + c.find({}, {sort: {'a.b': -1}}).fetch().map(doc => doc.a), + [{b: {c: 1}}, {b: 3}, {b: 2}, {b: 1}, 1, undefined]); + + // no such mid level prop. just test that it doesn't throw. + test.equal(c.find({}, {sort: {'a.nope.c': -1}}).count(), 6); +}); + +Tinytest.add('minimongo - array sort', test => { + const c = new LocalCollection(); + + // "up" and "down" are the indices that the docs should have when sorted + // ascending and descending by "a.x" respectively. They are not reverses of + // each other: when sorting ascending, you use the minimum value you can find + // in the document, and when sorting descending, you use the maximum value you + // can find. So [1, 4] shows up in the 1 slot when sorting ascending and the 4 + // slot when sorting descending. + // + // Similarly, "selected" is the index that the doc should have in the query + // that sorts ascending on "a.x" and selects {'a.x': {$gt: 1}}. In this case, + // the 1 in [1, 4] may not be used as a sort key. + c.insert({up: 1, down: 1, selected: 2, a: {x: [1, 4]}}); + c.insert({up: 2, down: 2, selected: 0, a: [{x: [2]}, {x: 3}]}); + c.insert({up: 0, down: 4, a: {x: 0}}); + c.insert({up: 3, down: 3, selected: 1, a: {x: 2.5}}); + c.insert({up: 4, down: 0, selected: 3, a: {x: 5}}); + + // Test that the the documents in "cursor" contain values with the name + // "field" running from 0 to the max value of that name in the collection. + const testCursorMatchesField = (cursor, field) => { + const fieldValues = []; + c.find().forEach(doc => { + if (hasOwn.call(doc, field)) {fieldValues.push(doc[field]);} + }); + test.equal(cursor.fetch().map(doc => doc[field]), + Array.from({length: Math.max(...fieldValues) + 1}, (x, i) => i)); + }; + + testCursorMatchesField(c.find({}, {sort: {'a.x': 1}}), 'up'); + testCursorMatchesField(c.find({}, {sort: {'a.x': -1}}), 'down'); + testCursorMatchesField(c.find({'a.x': {$gt: 1}}, {sort: {'a.x': 1}}), + 'selected'); +}); + +Tinytest.add('minimongo - sort keys', test => { + const keyListToObject = keyList => { + const obj = {}; + keyList.forEach(key => { + obj[EJSON.stringify(key)] = true; + }); + return obj; + }; + + const testKeys = (sortSpec, doc, expectedKeyList) => { + const expectedKeys = keyListToObject(expectedKeyList); + const sorter = new Minimongo.Sorter(sortSpec); + + const actualKeyList = []; + sorter._generateKeysFromDoc(doc, key => { + actualKeyList.push(key); + }); + const actualKeys = keyListToObject(actualKeyList); + test.equal(actualKeys, expectedKeys); + }; + + const testParallelError = (sortSpec, doc) => { + const sorter = new Minimongo.Sorter(sortSpec); + test.throws(() => { + sorter._generateKeysFromDoc(doc, () => {}); + }, /parallel arrays/); + }; + + // Just non-array fields. + testKeys({'a.x': 1, 'a.y': 1}, + {a: {x: 0, y: 5}}, + [[0, 5]]); + + // Ensure that we don't get [0,3] and [1,5]. + testKeys({'a.x': 1, 'a.y': 1}, + {a: [{x: 0, y: 5}, {x: 1, y: 3}]}, + [[0, 5], [1, 3]]); + + // Ensure we can combine "array fields" with "non-array fields". + testKeys({'a.x': 1, 'a.y': 1, b: -1}, + {a: [{x: 0, y: 5}, {x: 1, y: 3}], b: 42}, + [[0, 5, 42], [1, 3, 42]]); + testKeys({b: -1, 'a.x': 1, 'a.y': 1}, + {a: [{x: 0, y: 5}, {x: 1, y: 3}], b: 42}, + [[42, 0, 5], [42, 1, 3]]); + testKeys({'a.x': 1, b: -1, 'a.y': 1}, + {a: [{x: 0, y: 5}, {x: 1, y: 3}], b: 42}, + [[0, 42, 5], [1, 42, 3]]); + testKeys({a: 1, b: 1}, + {a: [1, 2, 3], b: 42}, + [[1, 42], [2, 42], [3, 42]]); + + // Don't support multiple arrays at the same level. + testParallelError({a: 1, b: 1}, + {a: [1, 2, 3], b: [42]}); + + // We are MORE STRICT than Mongo here; Mongo supports this! + // XXX support this too #NestedArraySort + testParallelError({'a.x': 1, 'a.y': 1}, + {a: [{x: 1, y: [2, 3]}, + {x: 2, y: [4, 5]}]}); +}); + +Tinytest.add('minimongo - sort key filter', test => { + const testOrder = (sortSpec, selector, doc1, doc2) => { + const matcher = new Minimongo.Matcher(selector); + const sorter = new Minimongo.Sorter(sortSpec, {matcher}); + const comparator = sorter.getComparator(); + const comparison = comparator(doc1, doc2); + test.isTrue(comparison < 0); + }; + + testOrder({'a.x': 1}, {'a.x': {$gt: 1}}, + {a: {x: 3}}, + {a: {x: [1, 4]}}); + testOrder({'a.x': 1}, {'a.x': {$gt: 0}}, + {a: {x: [1, 4]}}, + {a: {x: 3}}); + + const keyCompatible = (sortSpec, selector, key, compatible) => { + const matcher = new Minimongo.Matcher(selector); + const sorter = new Minimongo.Sorter(sortSpec, {matcher}); + const actual = sorter._keyCompatibleWithSelector(key); + test.equal(actual, compatible); + }; + + keyCompatible({a: 1}, {a: 5}, [5], true); + keyCompatible({a: 1}, {a: 5}, [8], false); + keyCompatible({a: 1}, {a: {x: 5}}, [{x: 5}], true); + keyCompatible({a: 1}, {a: {x: 5}}, [{x: 5, y: 9}], false); + keyCompatible({'a.x': 1}, {a: {x: 5}}, [5], true); + // To confirm this: + // > db.x.insert({_id: "q", a: [{x:1}, {x:5}], b: 2}) + // > db.x.insert({_id: "w", a: [{x:5}, {x:10}], b: 1}) + // > db.x.find({}).sort({'a.x': 1, b: 1}) + // { "_id" : "q", "a" : [ { "x" : 1 }, { "x" : 5 } ], "b" : 2 } + // { "_id" : "w", "a" : [ { "x" : 5 }, { "x" : 10 } ], "b" : 1 } + // > db.x.find({a: {x:5}}).sort({'a.x': 1, b: 1}) + // { "_id" : "q", "a" : [ { "x" : 1 }, { "x" : 5 } ], "b" : 2 } + // { "_id" : "w", "a" : [ { "x" : 5 }, { "x" : 10 } ], "b" : 1 } + // > db.x.find({'a.x': 5}).sort({'a.x': 1, b: 1}) + // { "_id" : "w", "a" : [ { "x" : 5 }, { "x" : 10 } ], "b" : 1 } + // { "_id" : "q", "a" : [ { "x" : 1 }, { "x" : 5 } ], "b" : 2 } + // ie, only the last one manages to trigger the key compatibility code, + // not the previous one. (The "b" sort is necessary because when the key + // compatibility code *does* kick in, both documents only end up with "5" + // for the first field as their only sort key, and we need to differentiate + // somehow...) + keyCompatible({'a.x': 1}, {a: {x: 5}}, [1], true); + keyCompatible({'a.x': 1}, {'a.x': 5}, [5], true); + keyCompatible({'a.x': 1}, {'a.x': 5}, [1], false); + + // Regex key check. + keyCompatible({a: 1}, {a: /^foo+/}, ['foo'], true); + keyCompatible({a: 1}, {a: /^foo+/}, ['foooo'], true); + keyCompatible({a: 1}, {a: /^foo+/}, ['foooobar'], true); + keyCompatible({a: 1}, {a: /^foo+/}, ['afoooo'], false); + keyCompatible({a: 1}, {a: /^foo+/}, [''], false); + keyCompatible({a: 1}, {a: {$regex: '^foo+'}}, ['foo'], true); + keyCompatible({a: 1}, {a: {$regex: '^foo+'}}, ['foooo'], true); + keyCompatible({a: 1}, {a: {$regex: '^foo+'}}, ['foooobar'], true); + keyCompatible({a: 1}, {a: {$regex: '^foo+'}}, ['afoooo'], false); + keyCompatible({a: 1}, {a: {$regex: '^foo+'}}, [''], false); + + keyCompatible({a: 1}, {a: /^foo+/i}, ['foo'], true); + // Key compatibility check appears to be turned off for regexps with flags. + keyCompatible({a: 1}, {a: /^foo+/i}, ['bar'], true); + keyCompatible({a: 1}, {a: /^foo+/m}, ['bar'], true); + keyCompatible({a: 1}, {a: {$regex: '^foo+', $options: 'i'}}, ['bar'], true); + keyCompatible({a: 1}, {a: {$regex: '^foo+', $options: 'm'}}, ['bar'], true); + + // Multiple keys! + keyCompatible({a: 1, b: 1, c: 1}, + {a: {$gt: 5}, c: {$lt: 3}}, [6, 'bla', 2], true); + keyCompatible({a: 1, b: 1, c: 1}, + {a: {$gt: 5}, c: {$lt: 3}}, [6, 'bla', 4], false); + keyCompatible({a: 1, b: 1, c: 1}, + {a: {$gt: 5}, c: {$lt: 3}}, [3, 'bla', 1], false); + // No filtering is done (ie, all keys are compatible) if the first key isn't + // constrained. + keyCompatible({a: 1, b: 1, c: 1}, + {c: {$lt: 3}}, [3, 'bla', 4], true); +}); + +Tinytest.add('minimongo - sort function', test => { + const c = new LocalCollection(); + + c.insert({a: 1}); + c.insert({a: 10}); + c.insert({a: 5}); + c.insert({a: 7}); + c.insert({a: 2}); + c.insert({a: 4}); + c.insert({a: 3}); + + const sortFunction = (doc1, doc2) => doc2.a - doc1.a; + + test.equal(c.find({}, {sort: sortFunction}).fetch(), c.find({}).fetch().sort(sortFunction)); + test.notEqual(c.find({}).fetch(), c.find({}).fetch().sort(sortFunction)); + test.equal(c.find({}, {sort: {a: -1}}).fetch(), c.find({}).fetch().sort(sortFunction)); +}); + +Tinytest.add('minimongo - binary search', test => { + const forwardCmp = (a, b) => a - b; + + const backwardCmp = (a, b) => -1 * forwardCmp(a, b); + + const checkSearch = (cmp, array, value, expected, message) => { + const actual = LocalCollection._binarySearch(cmp, array, value); + if (expected != actual) { + test.fail({type: 'minimongo-binary-search', + message: `${message} : Expected index ${expected} but had ${actual}`, + }); + } + }; + + const checkSearchForward = (array, value, expected, message) => { + checkSearch(forwardCmp, array, value, expected, message); + }; + const checkSearchBackward = (array, value, expected, message) => { + checkSearch(backwardCmp, array, value, expected, message); + }; + + checkSearchForward([1, 2, 5, 7], 4, 2, 'Inner insert'); + checkSearchForward([1, 2, 3, 4], 3, 3, 'Inner insert, equal value'); + checkSearchForward([1, 2, 5], 4, 2, 'Inner insert, odd length'); + checkSearchForward([1, 3, 5, 6], 9, 4, 'End insert'); + checkSearchForward([1, 3, 5, 6], 0, 0, 'Beginning insert'); + checkSearchForward([1], 0, 0, 'Single array, less than.'); + checkSearchForward([1], 1, 1, 'Single array, equal.'); + checkSearchForward([1], 2, 1, 'Single array, greater than.'); + checkSearchForward([], 1, 0, 'Empty array'); + checkSearchForward([1, 1, 1, 2, 2, 2, 2], 1, 3, 'Highly degenerate array, lower'); + checkSearchForward([1, 1, 1, 2, 2, 2, 2], 2, 7, 'Highly degenerate array, upper'); + checkSearchForward([2, 2, 2, 2, 2, 2, 2], 1, 0, 'Highly degenerate array, lower'); + checkSearchForward([2, 2, 2, 2, 2, 2, 2], 2, 7, 'Highly degenerate array, equal'); + checkSearchForward([2, 2, 2, 2, 2, 2, 2], 3, 7, 'Highly degenerate array, upper'); + + checkSearchBackward([7, 5, 2, 1], 4, 2, 'Backward: Inner insert'); + checkSearchBackward([4, 3, 2, 1], 3, 2, 'Backward: Inner insert, equal value'); + checkSearchBackward([5, 2, 1], 4, 1, 'Backward: Inner insert, odd length'); + checkSearchBackward([6, 5, 3, 1], 9, 0, 'Backward: Beginning insert'); + checkSearchBackward([6, 5, 3, 1], 0, 4, 'Backward: End insert'); + checkSearchBackward([1], 0, 1, 'Backward: Single array, less than.'); + checkSearchBackward([1], 1, 1, 'Backward: Single array, equal.'); + checkSearchBackward([1], 2, 0, 'Backward: Single array, greater than.'); + checkSearchBackward([], 1, 0, 'Backward: Empty array'); + checkSearchBackward([2, 2, 2, 2, 1, 1, 1], 1, 7, 'Backward: Degenerate array, lower'); + checkSearchBackward([2, 2, 2, 2, 1, 1, 1], 2, 4, 'Backward: Degenerate array, upper'); + checkSearchBackward([2, 2, 2, 2, 2, 2, 2], 1, 7, 'Backward: Highly degenerate array, upper'); + checkSearchBackward([2, 2, 2, 2, 2, 2, 2], 2, 7, 'Backward: Highly degenerate array, upper'); + checkSearchBackward([2, 2, 2, 2, 2, 2, 2], 3, 0, 'Backward: Highly degenerate array, upper'); +}); + +Tinytest.add('minimongo - modify', test => { + const modifyWithQuery = (doc, query, mod, expected) => { + const coll = new LocalCollection; + coll.insert(doc); + // The query is relevant for 'a.$.b'. + coll.update(query, mod); + const actual = coll.findOne(); + + if (!expected._id) { + delete actual._id; // added by insert + } + + if (typeof expected === 'function') { + expected(actual, EJSON.stringify({input: doc, mod})); + } else { + test.equal(actual, expected, EJSON.stringify({input: doc, mod})); + } + }; + const modify = (doc, mod, expected) => { + modifyWithQuery(doc, {}, mod, expected); + }; + const exceptionWithQuery = (doc, query, mod) => { + const coll = new LocalCollection; + coll.insert(doc); + test.throws(() => { + coll.update(query, mod); + }); + }; + const exception = (doc, mod) => { + exceptionWithQuery(doc, {}, mod); + }; + + const upsert = (query, mod, expected) => { + const coll = new LocalCollection; + + const result = coll.upsert(query, mod); + + const actual = coll.findOne(); + + if (expected._id) { + test.equal(result.insertedId, expected._id); + } else { + delete actual._id; + } + + test.equal(actual, expected); + }; + + const upsertUpdate = (initialDoc, query, mod, expected) => { + const collection = new LocalCollection; + + collection.insert(initialDoc); + + const result = collection.upsert(query, mod); + const actual = collection.findOne(); + + if (!expected._id) { + delete actual._id; + } + + test.equal(actual, expected); + }; + + const upsertException = (query, mod) => { + const coll = new LocalCollection; + test.throws(() => { + coll.upsert(query, mod); + }); + }; + + // document replacement + modify({}, {}, {}); + modify({a: 12}, {}, {}); // tested against mongodb + modify({a: 12}, {a: 13}, {a: 13}); + modify({a: 12, b: 99}, {a: 13}, {a: 13}); + exception({a: 12}, {a: 13, $set: {b: 13}}); + exception({a: 12}, {$set: {b: 13}, a: 13}); + + exception({a: 12}, {$a: 13}); // invalid operator + exception({a: 12}, {b: {$a: 13}}); + exception({a: 12}, {b: {'a.b': 13}}); + exception({a: 12}, {b: {'\0a': 13}}); + + // keys + modify({}, {$set: {a: 12}}, {a: 12}); + modify({}, {$set: {'a.b': 12}}, {a: {b: 12}}); + modify({}, {$set: {'a.b.c': 12}}, {a: {b: {c: 12}}}); + modify({a: {d: 99}}, {$set: {'a.b.c': 12}}, {a: {d: 99, b: {c: 12}}}); + modify({}, {$set: {'a.b.3.c': 12}}, {a: {b: {3: {c: 12}}}}); + modify({a: {b: []}}, {$set: {'a.b.3.c': 12}}, { + a: {b: [null, null, null, {c: 12}]}}); + exception({a: [null, null, null]}, {$set: {'a.1.b': 12}}); + exception({a: [null, 1, null]}, {$set: {'a.1.b': 12}}); + exception({a: [null, 'x', null]}, {$set: {'a.1.b': 12}}); + exception({a: [null, [], null]}, {$set: {'a.1.b': 12}}); + modify({a: [null, null, null]}, {$set: {'a.3.b': 12}}, { + a: [null, null, null, {b: 12}]}); + exception({a: []}, {$set: {'a.b': 12}}); + exception({a: 12}, {$set: {'a.b': 99}}); // tested on mongo + exception({a: 'x'}, {$set: {'a.b': 99}}); + exception({a: true}, {$set: {'a.b': 99}}); + exception({a: null}, {$set: {'a.b': 99}}); + modify({a: {}}, {$set: {'a.3': 12}}, {a: {3: 12}}); + modify({a: []}, {$set: {'a.3': 12}}, {a: [null, null, null, 12]}); + exception({}, {$set: {'': 12}}); // tested on mongo + exception({}, {$set: {'.': 12}}); // tested on mongo + exception({}, {$set: {'a.': 12}}); // tested on mongo + exception({}, {$set: {'. ': 12}}); // tested on mongo + exception({}, {$inc: {'... ': 12}}); // tested on mongo + exception({}, {$set: {'a..b': 12}}); // tested on mongo + modify({a: [1, 2, 3]}, {$set: {'a.01': 99}}, {a: [1, 99, 3]}); + modify({a: [1, {a: 98}, 3]}, {$set: {'a.01.b': 99}}, {a: [1, {a: 98, b: 99}, 3]}); + modify({}, {$set: {'2.a.b': 12}}, {2: {a: {b: 12}}}); // tested + exception({x: []}, {$set: {'x.2..a': 99}}); + modify({x: [null, null]}, {$set: {'x.2.a': 1}}, {x: [null, null, {a: 1}]}); + exception({x: [null, null]}, {$set: {'x.1.a': 1}}); + + // a.$.b + modifyWithQuery({a: [{x: 2}, {x: 4}]}, {'a.x': 4}, {$set: {'a.$.z': 9}}, + {a: [{x: 2}, {x: 4, z: 9}]}); + exception({a: [{x: 2}, {x: 4}]}, {$set: {'a.$.z': 9}}); + exceptionWithQuery({a: [{x: 2}, {x: 4}], b: 5}, {b: 5}, {$set: {'a.$.z': 9}}); + // can't have two $ + exceptionWithQuery({a: [{x: [2]}]}, {'a.x': 2}, {$set: {'a.$.x.$': 9}}); + modifyWithQuery({a: [5, 6, 7]}, {a: 6}, {$set: {'a.$': 9}}, {a: [5, 9, 7]}); + modifyWithQuery({a: [{b: [{c: 9}, {c: 10}]}, {b: {c: 11}}]}, {'a.b.c': 10}, + {$unset: {'a.$.b': 1}}, {a: [{}, {b: {c: 11}}]}); + modifyWithQuery({a: [{b: [{c: 9}, {c: 10}]}, {b: {c: 11}}]}, {'a.b.c': 11}, + {$unset: {'a.$.b': 1}}, + {a: [{b: [{c: 9}, {c: 10}]}, {}]}); + modifyWithQuery({a: [1]}, {'a.0': 1}, {$set: {'a.$': 5}}, {a: [5]}); + modifyWithQuery({a: [9]}, {a: {$mod: [2, 1]}}, {$set: {'a.$': 5}}, {a: [5]}); + // Negatives don't set '$'. + exceptionWithQuery({a: [1]}, {$not: {a: 2}}, {$set: {'a.$': 5}}); + exceptionWithQuery({a: [1]}, {'a.0': {$ne: 2}}, {$set: {'a.$': 5}}); + // One $or clause works. + modifyWithQuery({a: [{x: 2}, {x: 4}]}, + {$or: [{'a.x': 4}]}, {$set: {'a.$.z': 9}}, + {a: [{x: 2}, {x: 4, z: 9}]}); + // More $or clauses throw. + exceptionWithQuery({a: [{x: 2}, {x: 4}]}, + {$or: [{'a.x': 4}, {'a.x': 4}]}, + {$set: {'a.$.z': 9}}); + // $and uses the last one. + modifyWithQuery({a: [{x: 1}, {x: 3}]}, + {$and: [{'a.x': 1}, {'a.x': 3}]}, + {$set: {'a.$.x': 5}}, + {a: [{x: 1}, {x: 5}]}); + modifyWithQuery({a: [{x: 1}, {x: 3}]}, + {$and: [{'a.x': 3}, {'a.x': 1}]}, + {$set: {'a.$.x': 5}}, + {a: [{x: 5}, {x: 3}]}); + // Same goes for the implicit AND of a document selector. + modifyWithQuery({a: [{x: 1}, {y: 3}]}, + {'a.x': 1, 'a.y': 3}, + {$set: {'a.$.z': 5}}, + {a: [{x: 1}, {y: 3, z: 5}]}); + modifyWithQuery({a: [{x: 1}, {y: 1}, {x: 1, y: 1}]}, + {a: {$elemMatch: {x: 1, y: 1}}}, + {$set: {'a.$.x': 2}}, + {a: [{x: 1}, {y: 1}, {x: 2, y: 1}]}); + modifyWithQuery({a: [{b: [{x: 1}, {y: 1}, {x: 1, y: 1}]}]}, + {'a.b': {$elemMatch: {x: 1, y: 1}}}, + {$set: {'a.$.b': 3}}, + {a: [{b: 3}]}); + // with $near, make sure it does not find the closest one (#3599) + modifyWithQuery({a: []}, + {'a.b': {$near: [5, 5]}}, + {$set: {'a.$.b': 'k'}}, + {a: []}); + modifyWithQuery({a: [{b: [ [3, 3], [4, 4] ]}]}, + {'a.b': {$near: [5, 5]}}, + {$set: {'a.$.b': 'k'}}, + {a: [{b: 'k'}]}); + modifyWithQuery({a: [{b: [1, 1]}, + {b: [ [3, 3], [4, 4] ]}, + {b: [9, 9]}]}, + {'a.b': {$near: [5, 5]}}, + {$set: {'a.$.b': 'k'}}, + {a: [{b: 'k'}, {b: [[3, 3], [4, 4]]}, {b: [9, 9]}]}); + modifyWithQuery({a: [{b: [1, 1]}, + {b: [ [3, 3], [4, 4] ]}, + {b: [9, 9]}]}, + {'a.b': {$near: [9, 9], $maxDistance: 1}}, + {$set: {'a.$.b': 'k'}}, + {a: [{b: 'k'}, {b: [[3, 3], [4, 4]]}, {b: [9, 9]}]}); + modifyWithQuery({a: [{b: [1, 1]}, + {b: [ [3, 3], [4, 4] ]}, + {b: [9, 9]}]}, + {'a.b': {$near: [9, 9]}}, + {$set: {'a.$.b': 'k'}}, + {a: [{b: 'k'}, {b: [[3, 3], [4, 4]]}, {b: [9, 9]}]}); + modifyWithQuery({a: [{b: [9, 9]}, + {b: [ [3, 3], [4, 4] ]}, + {b: [9, 9]}]}, + {'a.b': {$near: [9, 9]}}, + {$set: {'a.$.b': 'k'}}, + {a: [{b: 'k'}, {b: [[3, 3], [4, 4]]}, {b: [9, 9]}]}); + modifyWithQuery({a: [{b: [4, 3]}, + {c: [1, 1]}]}, + {'a.c': {$near: [1, 1]}}, + {$set: {'a.$.c': 'k'}}, + {a: [{c: 'k', b: [4, 3]}, {c: [1, 1]}]}); + modifyWithQuery({a: [{c: [9, 9]}, + {b: [ [3, 3], [4, 4] ]}, + {b: [1, 1]}]}, + {'a.b': {$near: [1, 1]}}, + {$set: {'a.$.b': 'k'}}, + {a: [{c: [9, 9], b: 'k'}, {b: [ [3, 3], [4, 4]]}, {b: [1, 1]}]}); + modifyWithQuery({a: [{c: [9, 9], b: [4, 3]}, + {b: [ [3, 3], [4, 4] ]}, + {b: [1, 1]}]}, + {'a.b': {$near: [1, 1]}}, + {$set: {'a.$.b': 'k'}}, + {a: [{c: [9, 9], b: 'k'}, {b: [ [3, 3], [4, 4]]}, {b: [1, 1]}]}); + + // $inc + modify({a: 1, b: 2}, {$inc: {a: 10}}, {a: 11, b: 2}); + modify({a: 1, b: 2}, {$inc: {c: 10}}, {a: 1, b: 2, c: 10}); + exception({a: 1}, {$inc: {a: '10'}}); + exception({a: 1}, {$inc: {a: true}}); + exception({a: 1}, {$inc: {a: [10]}}); + exception({a: '1'}, {$inc: {a: 10}}); + exception({a: [1]}, {$inc: {a: 10}}); + exception({a: {}}, {$inc: {a: 10}}); + exception({a: false}, {$inc: {a: 10}}); + exception({a: null}, {$inc: {a: 10}}); + modify({a: [1, 2]}, {$inc: {'a.1': 10}}, {a: [1, 12]}); + modify({a: [1, 2]}, {$inc: {'a.2': 10}}, {a: [1, 2, 10]}); + modify({a: [1, 2]}, {$inc: {'a.3': 10}}, {a: [1, 2, null, 10]}); + modify({a: {b: 2}}, {$inc: {'a.b': 10}}, {a: {b: 12}}); + modify({a: {b: 2}}, {$inc: {'a.c': 10}}, {a: {b: 2, c: 10}}); + exception({}, {$inc: {_id: 1}}); + + // $currentDate + modify({}, {$currentDate: {a: true}}, (result, msg) => { test.instanceOf(result.a, Date, msg); }); + modify({}, {$currentDate: {a: {$type: 'date'}}}, (result, msg) => { test.instanceOf(result.a, Date, msg); }); + exception({}, {$currentDate: {a: false}}); + exception({}, {$currentDate: {a: {}}}); + exception({}, {$currentDate: {a: {$type: 'timestamp'}}}); + + // $min + modify({a: 1, b: 2}, {$min: {b: 1}}, {a: 1, b: 1}); + modify({a: 1, b: 2}, {$min: {b: 3}}, {a: 1, b: 2}); + modify({a: 1, b: 2}, {$min: {c: 10}}, {a: 1, b: 2, c: 10}); + exception({a: 1}, {$min: {a: '10'}}); + exception({a: 1}, {$min: {a: true}}); + exception({a: 1}, {$min: {a: [10]}}); + exception({a: '1'}, {$min: {a: 10}}); + exception({a: [1]}, {$min: {a: 10}}); + exception({a: {}}, {$min: {a: 10}}); + exception({a: false}, {$min: {a: 10}}); + exception({a: null}, {$min: {a: 10}}); + modify({a: [1, 2]}, {$min: {'a.1': 1}}, {a: [1, 1]}); + modify({a: [1, 2]}, {$min: {'a.1': 3}}, {a: [1, 2]}); + modify({a: [1, 2]}, {$min: {'a.2': 10}}, {a: [1, 2, 10]}); + modify({a: [1, 2]}, {$min: {'a.3': 10}}, {a: [1, 2, null, 10]}); + modify({a: {b: 2}}, {$min: {'a.b': 1}}, {a: {b: 1}}); + modify({a: {b: 2}}, {$min: {'a.c': 10}}, {a: {b: 2, c: 10}}); + exception({}, {$min: {_id: 1}}); + + // $max + modify({a: 1, b: 2}, {$max: {b: 1}}, {a: 1, b: 2}); + modify({a: 1, b: 2}, {$max: {b: 3}}, {a: 1, b: 3}); + modify({a: 1, b: 2}, {$max: {c: 10}}, {a: 1, b: 2, c: 10}); + exception({a: 1}, {$max: {a: '10'}}); + exception({a: 1}, {$max: {a: true}}); + exception({a: 1}, {$max: {a: [10]}}); + exception({a: '1'}, {$max: {a: 10}}); + exception({a: [1]}, {$max: {a: 10}}); + exception({a: {}}, {$max: {a: 10}}); + exception({a: false}, {$max: {a: 10}}); + exception({a: null}, {$max: {a: 10}}); + modify({a: [1, 2]}, {$max: {'a.1': 3}}, {a: [1, 3]}); + modify({a: [1, 2]}, {$max: {'a.1': 1}}, {a: [1, 2]}); + modify({a: [1, 2]}, {$max: {'a.2': 10}}, {a: [1, 2, 10]}); + modify({a: [1, 2]}, {$max: {'a.3': 10}}, {a: [1, 2, null, 10]}); + modify({a: {b: 2}}, {$max: {'a.b': 3}}, {a: {b: 3}}); + modify({a: {b: 2}}, {$max: {'a.c': 10}}, {a: {b: 2, c: 10}}); + exception({}, {$max: {_id: 1}}); + + // $set + modify({a: 1, b: 2}, {$set: {a: 10}}, {a: 10, b: 2}); + modify({a: 1, b: 2}, {$set: {c: 10}}, {a: 1, b: 2, c: 10}); + modify({a: 1, b: 2}, {$set: {a: {c: 10}}}, {a: {c: 10}, b: 2}); + modify({a: [1, 2], b: 2}, {$set: {a: [3, 4]}}, {a: [3, 4], b: 2}); + modify({a: [1, 2, 3], b: 2}, {$set: {'a.1': [3, 4]}}, + {a: [1, [3, 4], 3], b: 2}); + modify({a: [1], b: 2}, {$set: {'a.1': 9}}, {a: [1, 9], b: 2}); + modify({a: [1], b: 2}, {$set: {'a.2': 9}}, {a: [1, null, 9], b: 2}); + modify({a: {b: 1}}, {$set: {'a.c': 9}}, {a: {b: 1, c: 9}}); + modify({}, {$set: {'x._id': 4}}, {x: {_id: 4}}); + + // Changing _id is disallowed + exception({}, {$set: {_id: 4}}); + exception({_id: 1}, {$set: {_id: 4}}); + modify({_id: 4}, {$set: {_id: 4}}, {_id: 4}); // not-changing _id is not bad + // restricted field names + exception({a: {}}, {$set: {a: {$a: 1}}}); + exception({ a: {} }, { $set: { a: { c: + [{ b: { $a: 1 } }] } } }); + exception({a: {}}, {$set: {a: {'\0a': 1}}}); + exception({a: {}}, {$set: {a: {'a.b': 1}}}); + + // $unset + modify({}, {$unset: {a: 1}}, {}); + modify({a: 1}, {$unset: {a: 1}}, {}); + modify({a: 1, b: 2}, {$unset: {a: 1}}, {b: 2}); + modify({a: 1, b: 2}, {$unset: {a: 0}}, {b: 2}); + modify({a: 1, b: 2}, {$unset: {a: false}}, {b: 2}); + modify({a: 1, b: 2}, {$unset: {a: null}}, {b: 2}); + modify({a: 1, b: 2}, {$unset: {a: [1]}}, {b: 2}); + modify({a: 1, b: 2}, {$unset: {a: {}}}, {b: 2}); + modify({a: {b: 2, c: 3}}, {$unset: {'a.b': 1}}, {a: {c: 3}}); + modify({a: [1, 2, 3]}, {$unset: {'a.1': 1}}, {a: [1, null, 3]}); // tested + modify({a: [1, 2, 3]}, {$unset: {'a.2': 1}}, {a: [1, 2, null]}); // tested + modify({a: [1, 2, 3]}, {$unset: {'a.x': 1}}, {a: [1, 2, 3]}); // tested + modify({a: {b: 1}}, {$unset: {'a.b.c.d': 1}}, {a: {b: 1}}); + modify({a: {b: 1}}, {$unset: {'a.x.c.d': 1}}, {a: {b: 1}}); + modify({a: {b: {c: 1}}}, {$unset: {'a.b.c': 1}}, {a: {b: {}}}); + exception({}, {$unset: {_id: 1}}); + + // $push + modify({}, {$push: {a: 1}}, {a: [1]}); + modify({a: []}, {$push: {a: 1}}, {a: [1]}); + modify({a: [1]}, {$push: {a: 2}}, {a: [1, 2]}); + exception({a: true}, {$push: {a: 1}}); + modify({a: [1]}, {$push: {a: [2]}}, {a: [1, [2]]}); + modify({a: []}, {$push: {'a.1': 99}}, {a: [null, [99]]}); // tested + modify({a: {}}, {$push: {'a.x': 99}}, {a: {x: [99]}}); + modify({}, {$push: {a: {$each: [1, 2, 3]}}}, + {a: [1, 2, 3]}); + modify({a: []}, {$push: {a: {$each: [1, 2, 3]}}}, + {a: [1, 2, 3]}); + modify({a: [true]}, {$push: {a: {$each: [1, 2, 3]}}}, + {a: [true, 1, 2, 3]}); + modify({a: [true]}, {$push: {a: {$each: [1, 2, 3], $slice: -2}}}, + {a: [2, 3]}); + modify({a: [false, true]}, {$push: {a: {$each: [1], $slice: -2}}}, + {a: [true, 1]}); + modify( + {a: [{x: 3}, {x: 1}]}, + {$push: {a: { + $each: [{x: 4}, {x: 2}], + $slice: -2, + $sort: {x: 1}, + }}}, + {a: [{x: 3}, {x: 4}]}); + modify({}, {$push: {a: {$each: [1, 2, 3], $slice: 0}}}, {a: []}); + modify({a: [1, 2]}, {$push: {a: {$each: [1, 2, 3], $slice: 0}}}, {a: []}); + // $push with $position modifier + // No negative number for $position + exception({a: []}, {$push: {a: {$each: [0], $position: -1}}}); + modify({a: [1, 2]}, {$push: {a: {$each: [0], $position: 0}}}, + {a: [0, 1, 2]}); + modify({a: [1, 2]}, {$push: {a: {$each: [-1, 0], $position: 0}}}, + {a: [-1, 0, 1, 2]}); + modify({a: [1, 3]}, {$push: {a: {$each: [2], $position: 1}}}, {a: [1, 2, 3]}); + modify({a: [1, 4]}, {$push: {a: {$each: [2, 3], $position: 1}}}, + {a: [1, 2, 3, 4]}); + modify({a: [1, 2]}, {$push: {a: {$each: [3], $position: 3}}}, {a: [1, 2, 3]}); + modify({a: [1, 2]}, {$push: {a: {$each: [3], $position: 99}}}, + {a: [1, 2, 3]}); + modify({a: [1, 2]}, {$push: {a: {$each: [3], $position: 99, $slice: -2}}}, + {a: [2, 3]}); + modify( + {a: [{x: 1}, {x: 2}]}, + {$push: {a: {$each: [{x: 3}], $position: 0, $sort: {x: 1}, $slice: -3}}}, + {a: [{x: 1}, {x: 2}, {x: 3}]} + ); + modify( + {a: [{x: 1}, {x: 2}]}, + {$push: {a: {$each: [{x: 3}], $position: 0, $sort: {x: 1}, $slice: 0}}}, + {a: []} + ); + // restricted field names + exception({}, {$push: {$a: 1}}); + exception({}, {$push: {'\0a': 1}}); + exception({}, {$push: {a: {$a: 1}}}); + exception({}, {$push: {a: {$each: [{$a: 1}]}}}); + exception({}, {$push: {a: {$each: [{'a.b': 1}]}}}); + exception({}, {$push: {a: {$each: [{'\0a': 1}]}}}); + modify({}, {$push: {a: {$each: [{'': 1}]}}}, {a: [ { '': 1 } ]}); + modify({}, {$push: {a: {$each: [{' ': 1}]}}}, {a: [ { ' ': 1 } ]}); + exception({}, {$push: {a: {$each: [{'.': 1}]}}}); + + // #issue 5167 + // $push $slice with positive numbers + modify({}, {$push: {a: {$each: [], $slice: 5}}}, {a: []}); + modify({a: [1, 2, 3]}, {$push: {a: {$each: [], $slice: 1}}}, {a: [1]}); + modify({a: [1, 2, 3]}, {$push: {a: {$each: [4, 5], $slice: 1}}}, {a: [1]}); + modify({a: [1, 2, 3]}, {$push: {a: {$each: [4, 5], $slice: 2}}}, {a: [1, 2]}); + modify({a: [1, 2, 3]}, {$push: {a: {$each: [4, 5], $slice: 4}}}, {a: [1, 2, 3, 4]}); + modify({a: [1, 2, 3]}, {$push: {a: {$each: [4, 5], $slice: 5}}}, {a: [1, 2, 3, 4, 5]}); + modify({a: [1, 2, 3]}, {$push: {a: {$each: [4, 5], $slice: 10}}}, {a: [1, 2, 3, 4, 5]}); + + + // $pushAll + modify({}, {$pushAll: {a: [1]}}, {a: [1]}); + modify({a: []}, {$pushAll: {a: [1]}}, {a: [1]}); + modify({a: [1]}, {$pushAll: {a: [2]}}, {a: [1, 2]}); + modify({}, {$pushAll: {a: [1, 2]}}, {a: [1, 2]}); + modify({a: []}, {$pushAll: {a: [1, 2]}}, {a: [1, 2]}); + modify({a: [1]}, {$pushAll: {a: [2, 3]}}, {a: [1, 2, 3]}); + modify({}, {$pushAll: {a: []}}, {a: []}); + modify({a: []}, {$pushAll: {a: []}}, {a: []}); + modify({a: [1]}, {$pushAll: {a: []}}, {a: [1]}); + exception({a: true}, {$pushAll: {a: [1]}}); + exception({a: []}, {$pushAll: {a: 1}}); + modify({a: []}, {$pushAll: {'a.1': [99]}}, {a: [null, [99]]}); + modify({a: []}, {$pushAll: {'a.1': []}}, {a: [null, []]}); + modify({a: {}}, {$pushAll: {'a.x': [99]}}, {a: {x: [99]}}); + modify({a: {}}, {$pushAll: {'a.x': []}}, {a: {x: []}}); + exception({a: [1]}, {$pushAll: {a: [{$a: 1}]}}); + exception({a: [1]}, {$pushAll: {a: [{'\0a': 1}]}}); + exception({a: [1]}, {$pushAll: {a: [{'a.b': 1}]}}); + + // $addToSet + modify({}, {$addToSet: {a: 1}}, {a: [1]}); + modify({a: []}, {$addToSet: {a: 1}}, {a: [1]}); + modify({a: [1]}, {$addToSet: {a: 2}}, {a: [1, 2]}); + modify({a: [1, 2]}, {$addToSet: {a: 1}}, {a: [1, 2]}); + modify({a: [1, 2]}, {$addToSet: {a: 2}}, {a: [1, 2]}); + modify({a: [1, 2]}, {$addToSet: {a: 3}}, {a: [1, 2, 3]}); + exception({a: true}, {$addToSet: {a: 1}}); + modify({a: [1]}, {$addToSet: {a: [2]}}, {a: [1, [2]]}); + modify({}, {$addToSet: {a: {x: 1}}}, {a: [{x: 1}]}); + modify({a: [{x: 1}]}, {$addToSet: {a: {x: 1}}}, {a: [{x: 1}]}); + modify({a: [{x: 1}]}, {$addToSet: {a: {x: 2}}}, {a: [{x: 1}, {x: 2}]}); + modify({a: [{x: 1, y: 2}]}, {$addToSet: {a: {x: 1, y: 2}}}, + {a: [{x: 1, y: 2}]}); + modify({a: [{x: 1, y: 2}]}, {$addToSet: {a: {y: 2, x: 1}}}, + {a: [{x: 1, y: 2}, {y: 2, x: 1}]}); + modify({a: [1, 2]}, {$addToSet: {a: {$each: [3, 1, 4]}}}, {a: [1, 2, 3, 4]}); + modify({}, {$addToSet: {a: {$each: []}}}, {a: []}); + modify({}, {$addToSet: {a: {$each: [1]}}}, {a: [1]}); + modify({a: []}, {$addToSet: {'a.1': 99}}, {a: [null, [99]]}); + modify({a: {}}, {$addToSet: {'a.x': 99}}, {a: {x: [99]}}); + + // invalid field names + exception({}, {$addToSet: {a: {$b: 1}}}); + exception({}, {$addToSet: {a: {'a.b': 1}}}); + exception({}, {$addToSet: {a: {'a.': 1}}}); + exception({}, {$addToSet: {a: {'\u0000a': 1}}}); + exception({a: [1, 2]}, {$addToSet: {a: {$each: [3, 1, {$a: 1}]}}}); + exception({a: [1, 2]}, {$addToSet: {a: {$each: [3, 1, {'\0a': 1}]}}}); + exception({a: [1, 2]}, {$addToSet: {a: {$each: [3, 1, [{$a: 1}]]}}}); + exception({a: [1, 2]}, {$addToSet: {a: {$each: [3, 1, [{b: {c: [{a: 1}, {'d.s': 2}]}}]]}}}); + exception({a: [1, 2]}, {$addToSet: {a: {b: [3, 1, [{b: {c: [{a: 1}, {'d.s': 2}]}}]]}}}); + // $each is first element and thus an operator + modify({a: [1, 2]}, {$addToSet: {a: {$each: [3, 1, 4], b: 12}}}, {a: [ 1, 2, 3, 4 ]}); + // this should fail because $each is now a field name (not first in object) and thus invalid field name with $ + exception({a: [1, 2]}, {$addToSet: {a: {b: 12, $each: [3, 1, 4]}}}); + + // $pop + modify({}, {$pop: {a: 1}}, {}); // tested + modify({}, {$pop: {a: -1}}, {}); // tested + modify({a: []}, {$pop: {a: 1}}, {a: []}); + modify({a: []}, {$pop: {a: -1}}, {a: []}); + modify({a: [1, 2, 3]}, {$pop: {a: 1}}, {a: [1, 2]}); + modify({a: [1, 2, 3]}, {$pop: {a: 10}}, {a: [1, 2]}); + modify({a: [1, 2, 3]}, {$pop: {a: 0.001}}, {a: [1, 2]}); + modify({a: [1, 2, 3]}, {$pop: {a: 0}}, {a: [1, 2]}); + modify({a: [1, 2, 3]}, {$pop: {a: 'stuff'}}, {a: [1, 2]}); + modify({a: [1, 2, 3]}, {$pop: {a: -1}}, {a: [2, 3]}); + modify({a: [1, 2, 3]}, {$pop: {a: -10}}, {a: [2, 3]}); + modify({a: [1, 2, 3]}, {$pop: {a: -0.001}}, {a: [2, 3]}); + exception({a: true}, {$pop: {a: 1}}); + exception({a: true}, {$pop: {a: -1}}); + modify({a: []}, {$pop: {'a.1': 1}}, {a: []}); // tested + modify({a: [1, [2, 3], 4]}, {$pop: {'a.1': 1}}, {a: [1, [2], 4]}); + modify({a: {}}, {$pop: {'a.x': 1}}, {a: {}}); // tested + modify({a: {x: [2, 3]}}, {$pop: {'a.x': 1}}, {a: {x: [2]}}); + + // $pull + modify({}, {$pull: {a: 1}}, {}); + modify({}, {$pull: {'a.x': 1}}, {}); + modify({a: {}}, {$pull: {'a.x': 1}}, {a: {}}); + exception({a: true}, {$pull: {a: 1}}); + modify({a: [2, 1, 2]}, {$pull: {a: 1}}, {a: [2, 2]}); + modify({a: [2, 1, 2]}, {$pull: {a: 2}}, {a: [1]}); + modify({a: [2, 1, 2]}, {$pull: {a: 3}}, {a: [2, 1, 2]}); + modify({a: [1, null, 2, null]}, {$pull: {a: null}}, {a: [1, 2]}); + modify({a: []}, {$pull: {a: 3}}, {a: []}); + modify({a: [[2], [2, 1], [3]]}, {$pull: {a: [2, 1]}}, + {a: [[2], [3]]}); // tested + modify({a: [{b: 1, c: 2}, {b: 2, c: 2}]}, {$pull: {a: {b: 1}}}, + {a: [{b: 2, c: 2}]}); + modify({a: [{b: 1, c: 2}, {b: 2, c: 2}]}, {$pull: {a: {c: 2}}}, + {a: []}); + // XXX implement this functionality! + // probably same refactoring as $elemMatch? + // modify({a: [1, 2, 3, 4]}, {$pull: {$gt: 2}}, {a: [1,2]}); fails! + + // $pullAll + modify({}, {$pullAll: {a: [1]}}, {}); + modify({a: [1, 2, 3]}, {$pullAll: {a: []}}, {a: [1, 2, 3]}); + modify({a: [1, 2, 3]}, {$pullAll: {a: [2]}}, {a: [1, 3]}); + modify({a: [1, 2, 3]}, {$pullAll: {a: [2, 1]}}, {a: [3]}); + modify({a: [1, 2, 3]}, {$pullAll: {a: [1, 2]}}, {a: [3]}); + modify({}, {$pullAll: {'a.b.c': [2]}}, {}); + exception({a: true}, {$pullAll: {a: [1]}}); + exception({a: [1, 2, 3]}, {$pullAll: {a: 1}}); + modify({x: [{a: 1}, {a: 1, b: 2}]}, {$pullAll: {x: [{a: 1}]}}, + {x: [{a: 1, b: 2}]}); + + // $rename + modify({}, {$rename: {a: 'b'}}, {}); + modify({a: [12]}, {$rename: {a: 'b'}}, {b: [12]}); + modify({a: {b: 12}}, {$rename: {a: 'c'}}, {c: {b: 12}}); + modify({a: {b: 12}}, {$rename: {'a.b': 'a.c'}}, {a: {c: 12}}); + modify({a: {b: 12}}, {$rename: {'a.b': 'x'}}, {a: {}, x: 12}); // tested + modify({a: {b: 12}}, {$rename: {'a.b': 'q.r'}}, {a: {}, q: {r: 12}}); + modify({a: {b: 12}}, {$rename: {'a.b': 'q.2.r'}}, {a: {}, q: {2: {r: 12}}}); + modify({a: {b: 12}, q: {}}, {$rename: {'a.b': 'q.2.r'}}, + {a: {}, q: {2: {r: 12}}}); + exception({a: {b: 12}, q: []}, {$rename: {'a.b': 'q.2'}}); // tested + exception({a: {b: 12}, q: []}, {$rename: {'a.b': 'q.2.r'}}); // tested + // These strange MongoDB behaviors throw. + // modify({a: {b: 12}, q: []}, {$rename: {'q.1': 'x'}}, + // {a: {b: 12}, x: []}); // tested + // modify({a: {b: 12}, q: []}, {$rename: {'q.1.j': 'x'}}, + // {a: {b: 12}, x: []}); // tested + exception({}, {$rename: {a: 'a'}}); + exception({}, {$rename: {'a.b': 'a.b'}}); + modify({a: 12, b: 13}, {$rename: {a: 'b'}}, {b: 12}); + exception({a: [12]}, {$rename: {a: '$b'}}); + exception({a: [12]}, {$rename: {a: '\0a'}}); + + // $setOnInsert + modify({a: 0}, {$setOnInsert: {a: 12}}, {a: 0}); + upsert({a: 12}, {$setOnInsert: {b: 12}}, {a: 12, b: 12}); + upsert({a: 12}, {$setOnInsert: {_id: 'test'}}, {_id: 'test', a: 12}); + upsert({'a.b': 10}, {$setOnInsert: {a: {b: 10, c: 12}}}, {a: {b: 10, c: 12}}); + upsert({'a.b': 10}, {$setOnInsert: {c: 12}}, {a: {b: 10}, c: 12}); + upsert({_id: 'test'}, {$setOnInsert: {c: 12}}, {_id: 'test', c: 12}); + upsert('test', {$setOnInsert: {c: 12}}, {_id: 'test', c: 12}); + upsertException({a: 0}, {$setOnInsert: {$a: 12}}); + upsertException({a: 0}, {$setOnInsert: {'\0a': 12}}); + upsert({a: 0}, {$setOnInsert: {b: {a: 1}}}, {a: 0, b: {a: 1}}); + upsertException({a: 0}, {$setOnInsert: {b: {$a: 1}}}); + upsertException({a: 0}, {$setOnInsert: {b: {'a.b': 1}}}); + upsertException({a: 0}, {$setOnInsert: {b: {'\0a': 1}}}); + + // Test for https://github.com/meteor/meteor/issues/8775. + upsert( + { a: { $exists: true }}, + { $setOnInsert: { a: 123 }}, + { a: 123 } + ); + + // Tests for https://github.com/meteor/meteor/issues/8794. + const testObjectId = new MongoID.ObjectID(); + upsert( + { _id: testObjectId }, + { $setOnInsert: { a: 123 } }, + { _id: testObjectId, a: 123 }, + ); + upsert( + { someOtherId: testObjectId }, + { $setOnInsert: { a: 123 } }, + { someOtherId: testObjectId, a: 123 }, + ); + upsert( + { a: { $eq: testObjectId } }, + { $setOnInsert: { a: 123 } }, + { a: 123 }, + ); + const testDate = new Date('2017-01-01'); + upsert( + { someDate: testDate }, + { $setOnInsert: { a: 123 } }, + { someDate: testDate, a: 123 }, + ); + upsert( + { + a: Object.create(null, { + $exists: { + writable: true, + configurable: true, + value: true, + }, + }), + }, + { $setOnInsert: { a: 123 } }, + { a: 123 }, + ); + upsert( + { foo: { $exists: true, $type: 2 }}, + { $setOnInsert: { bar: 'baz' } }, + { bar: 'baz' } + ); + upsert( + { foo: {} }, + { $setOnInsert: { bar: 'baz' } }, + { foo: {}, bar: 'baz' } + ); + + // Tests for https://github.com/meteor/meteor/issues/8806 + upsert({"a": {"b": undefined, "c": null}}, {"$set": {"c": "foo"}}, {"a": {"b": undefined, "c": null}, "c": "foo"}) + upsert({"a": {"$eq": "bar" }}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) + // $all with 1 statement is similar to $eq + upsert({"a": {"$all": ["bar"] }}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) + upsert({"a": {"$eq": "bar" }, "b": "baz"}, {"$set": {"c": "foo"}}, {"a": "bar", "b": "baz", "c": "foo"}) + upsert({"a": {"$exists": true}}, {"$set": {"c": "foo"}}, {"c": "foo"}) + upsert({"a": {"$exists": true, "$eq": "foo"}}, {"$set": {"c": "foo"}}, {"a": "foo", "c": "foo"}) + upsert({"a": {"$gt": 3, "$eq": 2}}, {"$set": {"c": "foo"}}, {"a": 2, "c": "foo"}) + // $and + upsert({"$and": [{"a": {"$eq": "bar"}}]}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) + upsert({"$and": [{"a": {"$all": ["bar"]}}]}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) + upsert({"$and": [{"a": {"$all": ["bar"]}}]}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) + // $or with one statement is handled similar to $and + upsert({"$or": [{"a": "bar"}]}, {"$set": {"c": "foo"}}, {"a": "bar", "c": "foo"}) + // $or with multiple statements is ignored + upsert({"$or": [{"a": "bar"}, {"b": "baz"}]}, {"$set": {"c": "foo"}}, {"c": "foo"}) + // Negative logical operators are ignored + upsert({"$nor": [{"a": "bar"}]}, {"$set": {"c": "foo"}}, {"c": "foo"}) + // Filter out empty objects after filtering out operators + upsert({"a": {"$exists": true}}, {"$set": {"c": "foo"}}, {"c": "foo"}) + // But leave actual empty objects + upsert({"a": {}}, {"$set": {"c": "foo"}}, {"a": {}, "c": "foo"}) + // Also filter out shorthand regexp notation + upsert({"a": /a/}, {"$set": {"c": "foo"}}, {"c": "foo"}) + // Test nested fields + upsert({"$and": [{"a.a": "foo"}, {"$or": [{"a.b": "baz"}]}]}, {"$set": {"c": "foo"}}, {"a": {"a": "foo", "b": "baz"}, "c": "foo"}) + // Test for https://github.com/meteor/meteor/issues/5294 + upsert({"a": {"$ne": 444}}, {"$push": {"a": 123}}, {"a": [123]}) + // Mod takes precedence over query + upsert({"a": "foo"}, {"a": "bar"}, {"a": "bar"}) + upsert({"a": "foo"}, {"$set":{"a": "bar"}}, {"a": "bar"}) + // Replacement can take _id from query + upsert({"_id": "foo", "foo": "bar"}, {"bar": "foo"}, {"_id": "foo", "bar": "foo"}) + // Replacement update keeps _id + upsertUpdate({"_id": "foo", "bar": "baz"}, {"_id":"foo"}, {"bar": "crow"}, {"_id": "foo", "bar": "crow"}); + + // Nested fields don't work with literal objects + upsertException({"a": {}, "a.b": "foo"}, {}); + // You can't have an ambiguious ID + upsertException({"_id":"foo"}, {"_id":"bar"}); + upsertException({"_id":"foo"}, {"$set":{"_id":"bar"}}); + // You can't set the same field twice + upsertException({"$and": [{"a": "foo"}, {"a": "foo"}]}, {}); //not even with same value + upsertException({"a": {"$all": ["foo", "bar"]}}, {}); + upsertException({"$and": [{"a": {"$eq": "foo"}}, {"$or": [{"a": {"$all": ["bar"]}}]}]}, {}); + // You can't have nested dotted fields + upsertException({"a": {"foo.bar": "baz"}}, {}); + // You can't have dollar-prefixed fields above the first level (logical operators not counted) + upsertException({"a": {"a": {"$eq": "foo"}}}, {}); + upsertException({"a": {"a": {"$exists": true}}}, {}); + // You can't mix operators with other fields + upsertException({"a": {"$eq": "bar", "b": "foo"}}, {}) + upsertException({"a": {"b": "foo", "$eq": "bar"}}, {}) + + const mongoIdForUpsert = new MongoID.ObjectID('44915733af80844fa1cef07a'); + upsert({_id: mongoIdForUpsert}, {$setOnInsert: {a: 123}}, {a: 123}) + + // Test for https://github.com/meteor/meteor/issues/7758 + upsert({n_id: mongoIdForUpsert, c_n: "bar"}, + {$set: { t_t_o: "foo"}}, + {n_id: mongoIdForUpsert, t_t_o: "foo", c_n: "bar"}); + + exception({}, {$set: {_id: 'bad'}}); + + // $bit + // unimplemented + + // XXX test case sensitivity of modops + // XXX for each (most) modop, test that it performs a deep copy +}); + +// XXX test update() (selecting docs, multi, upsert..) + +Tinytest.add('minimongo - observe ordered', test => { + const operations = []; + const cbs = log_callbacks(operations); + let handle; + + const c = new LocalCollection(); + handle = c.find({}, {sort: {a: 1}}).observe(cbs); + test.isTrue(handle.collection === c); + + c.insert({_id: 'foo', a: 1}); + test.equal(operations.shift(), ['added', {a: 1}, 0, null]); + c.update({a: 1}, {$set: {a: 2}}); + test.equal(operations.shift(), ['changed', {a: 2}, 0, {a: 1}]); + c.insert({a: 10}); + test.equal(operations.shift(), ['added', {a: 10}, 1, null]); + c.update({}, {$inc: {a: 1}}, {multi: true}); + test.equal(operations.shift(), ['changed', {a: 3}, 0, {a: 2}]); + test.equal(operations.shift(), ['changed', {a: 11}, 1, {a: 10}]); + c.update({a: 11}, {a: 1}); + test.equal(operations.shift(), ['changed', {a: 1}, 1, {a: 11}]); + test.equal(operations.shift(), ['moved', {a: 1}, 1, 0, 'foo']); + c.remove({a: 2}); + test.equal(operations.shift(), undefined); + c.remove({a: 3}); + test.equal(operations.shift(), ['removed', 'foo', 1, {a: 3}]); + + // test stop + handle.stop(); + const idA2 = Random.id(); + c.insert({_id: idA2, a: 2}); + test.equal(operations.shift(), undefined); + + // test initial inserts (and backwards sort) + handle = c.find({}, {sort: {a: -1}}).observe(cbs); + test.equal(operations.shift(), ['added', {a: 2}, 0, null]); + test.equal(operations.shift(), ['added', {a: 1}, 1, null]); + handle.stop(); + + // test _suppress_initial + handle = c.find({}, {sort: {a: -1}}).observe(Object.assign({ + _suppress_initial: true}, cbs)); + test.equal(operations.shift(), undefined); + c.insert({a: 100}); + test.equal(operations.shift(), ['added', {a: 100}, 0, idA2]); + handle.stop(); + + // test skip and limit. + c.remove({}); + handle = c.find({}, {sort: {a: 1}, skip: 1, limit: 2}).observe(cbs); + test.equal(operations.shift(), undefined); + c.insert({a: 1}); + test.equal(operations.shift(), undefined); + c.insert({_id: 'foo', a: 2}); + test.equal(operations.shift(), ['added', {a: 2}, 0, null]); + c.insert({a: 3}); + test.equal(operations.shift(), ['added', {a: 3}, 1, null]); + c.insert({a: 4}); + test.equal(operations.shift(), undefined); + c.update({a: 1}, {a: 0}); + test.equal(operations.shift(), undefined); + c.update({a: 0}, {a: 5}); + test.equal(operations.shift(), ['removed', 'foo', 0, {a: 2}]); + test.equal(operations.shift(), ['added', {a: 4}, 1, null]); + c.update({a: 3}, {a: 3.5}); + test.equal(operations.shift(), ['changed', {a: 3.5}, 0, {a: 3}]); + handle.stop(); + + // test observe limit with pre-existing docs + c.remove({}); + c.insert({a: 1}); + c.insert({_id: 'two', a: 2}); + c.insert({a: 3}); + handle = c.find({}, {sort: {a: 1}, limit: 2}).observe(cbs); + test.equal(operations.shift(), ['added', {a: 1}, 0, null]); + test.equal(operations.shift(), ['added', {a: 2}, 1, null]); + test.equal(operations.shift(), undefined); + c.remove({a: 2}); + test.equal(operations.shift(), ['removed', 'two', 1, {a: 2}]); + test.equal(operations.shift(), ['added', {a: 3}, 1, null]); + test.equal(operations.shift(), undefined); + handle.stop(); + + // test _no_indices + + c.remove({}); + handle = c.find({}, {sort: {a: 1}}).observe(Object.assign(cbs, {_no_indices: true})); + c.insert({_id: 'foo', a: 1}); + test.equal(operations.shift(), ['added', {a: 1}, -1, null]); + c.update({a: 1}, {$set: {a: 2}}); + test.equal(operations.shift(), ['changed', {a: 2}, -1, {a: 1}]); + c.insert({a: 10}); + test.equal(operations.shift(), ['added', {a: 10}, -1, null]); + c.update({}, {$inc: {a: 1}}, {multi: true}); + test.equal(operations.shift(), ['changed', {a: 3}, -1, {a: 2}]); + test.equal(operations.shift(), ['changed', {a: 11}, -1, {a: 10}]); + c.update({a: 11}, {a: 1}); + test.equal(operations.shift(), ['changed', {a: 1}, -1, {a: 11}]); + test.equal(operations.shift(), ['moved', {a: 1}, -1, -1, 'foo']); + c.remove({a: 2}); + test.equal(operations.shift(), undefined); + c.remove({a: 3}); + test.equal(operations.shift(), ['removed', 'foo', -1, {a: 3}]); + handle.stop(); +}); + +[true, false].forEach(ordered => { + Tinytest.add(`minimongo - observe ordered: ${ordered}`, test => { + const c = new LocalCollection(); + + let ev = ''; + const makecb = tag => { + const ret = {}; + ['added', 'changed', 'removed'].forEach(fn => { + const fnName = ordered ? `${fn}At` : fn; + ret[fnName] = doc => { + ev = `${ev + fn.substr(0, 1) + tag + doc._id}_`; + }; + }); + return ret; + }; + const expect = x => { + test.equal(ev, x); + ev = ''; + }; + + c.insert({_id: 1, name: 'strawberry', tags: ['fruit', 'red', 'squishy']}); + c.insert({_id: 2, name: 'apple', tags: ['fruit', 'red', 'hard']}); + c.insert({_id: 3, name: 'rose', tags: ['flower', 'red', 'squishy']}); + + // This should work equally well for ordered and unordered observations + // (because the callbacks don't look at indices and there's no 'moved' + // callback). + let handle = c.find({tags: 'flower'}).observe(makecb('a')); + expect('aa3_'); + c.update({name: 'rose'}, {$set: {tags: ['bloom', 'red', 'squishy']}}); + expect('ra3_'); + c.update({name: 'rose'}, {$set: {tags: ['flower', 'red', 'squishy']}}); + expect('aa3_'); + c.update({name: 'rose'}, {$set: {food: false}}); + expect('ca3_'); + c.remove({}); + expect('ra3_'); + c.insert({_id: 4, name: 'daisy', tags: ['flower']}); + expect('aa4_'); + handle.stop(); + // After calling stop, no more callbacks are called. + c.insert({_id: 5, name: 'iris', tags: ['flower']}); + expect(''); + + // Test that observing a lookup by ID works. + handle = c.find(4).observe(makecb('b')); + expect('ab4_'); + c.update(4, {$set: {eek: 5}}); + expect('cb4_'); + handle.stop(); + + // Test observe with reactive: false. + handle = c.find({tags: 'flower'}, {reactive: false}).observe(makecb('c')); + expect('ac4_ac5_'); + // This insert shouldn't trigger a callback because it's not reactive. + c.insert({_id: 6, name: 'river', tags: ['flower']}); + expect(''); + handle.stop(); + }); +}); + + +Tinytest.add('minimongo - saveOriginals', test => { + // set up some data + const c = new LocalCollection(); + + let count; + c.insert({_id: 'foo', x: 'untouched'}); + c.insert({_id: 'bar', x: 'updateme'}); + c.insert({_id: 'baz', x: 'updateme'}); + c.insert({_id: 'quux', y: 'removeme'}); + c.insert({_id: 'whoa', y: 'removeme'}); + + // Save originals and make some changes. + c.saveOriginals(); + c.insert({_id: 'hooray', z: 'insertme'}); + c.remove({y: 'removeme'}); + count = c.update({x: 'updateme'}, {$set: {z: 5}}, {multi: true}); + c.update('bar', {$set: {k: 7}}); // update same doc twice + + // Verify returned count is correct + test.equal(count, 2); + + // Verify the originals. + let originals = c.retrieveOriginals(); + const affected = ['bar', 'baz', 'quux', 'whoa', 'hooray']; + test.equal(originals.size(), affected.length); + affected.forEach(id => { + test.isTrue(originals.has(id)); + }); + test.equal(originals.get('bar'), {_id: 'bar', x: 'updateme'}); + test.equal(originals.get('baz'), {_id: 'baz', x: 'updateme'}); + test.equal(originals.get('quux'), {_id: 'quux', y: 'removeme'}); + test.equal(originals.get('whoa'), {_id: 'whoa', y: 'removeme'}); + test.equal(originals.get('hooray'), undefined); + + // Verify that changes actually occured. + test.equal(c.find().count(), 4); + test.equal(c.findOne('foo'), {_id: 'foo', x: 'untouched'}); + test.equal(c.findOne('bar'), {_id: 'bar', x: 'updateme', z: 5, k: 7}); + test.equal(c.findOne('baz'), {_id: 'baz', x: 'updateme', z: 5}); + test.equal(c.findOne('hooray'), {_id: 'hooray', z: 'insertme'}); + + // The next call doesn't get the same originals again. + c.saveOriginals(); + originals = c.retrieveOriginals(); + test.isTrue(originals); + test.isTrue(originals.empty()); + + // Insert and remove a document during the period. + c.saveOriginals(); + c.insert({_id: 'temp', q: 8}); + c.remove('temp'); + originals = c.retrieveOriginals(); + test.equal(originals.size(), 1); + test.isTrue(originals.has('temp')); + test.equal(originals.get('temp'), undefined); +}); + +Tinytest.add('minimongo - saveOriginals errors', test => { + const c = new LocalCollection(); + // Can't call retrieve before save. + test.throws(() => { c.retrieveOriginals(); }); + c.saveOriginals(); + // Can't call save twice. + test.throws(() => { c.saveOriginals(); }); +}); + +Tinytest.add('minimongo - objectid transformation', test => { + const testId = item => { + test.equal(item, MongoID.idParse(MongoID.idStringify(item))); + }; + const randomOid = new MongoID.ObjectID(); + testId(randomOid); + testId('FOO'); + testId('ffffffffffff'); + testId('0987654321abcdef09876543'); + testId(new MongoID.ObjectID()); + testId('--a string'); + + test.equal('ffffffffffff', MongoID.idParse(MongoID.idStringify('ffffffffffff'))); +}); + + +Tinytest.add('minimongo - objectid', test => { + const randomOid = new MongoID.ObjectID(); + const anotherRandomOid = new MongoID.ObjectID(); + test.notEqual(randomOid, anotherRandomOid); + test.throws(() => { new MongoID.ObjectID('qqqqqqqqqqqqqqqqqqqqqqqq');}); + test.throws(() => { new MongoID.ObjectID('ABCDEF'); }); + test.equal(randomOid, new MongoID.ObjectID(randomOid.valueOf())); +}); + +Tinytest.add('minimongo - pause', test => { + const operations = []; + const cbs = log_callbacks(operations); + + const c = new LocalCollection(); + const h = c.find({}).observe(cbs); + + // remove and add cancel out. + c.insert({_id: 1, a: 1}); + test.equal(operations.shift(), ['added', {a: 1}, 0, null]); + + c.pauseObservers(); + + c.remove({_id: 1}); + test.length(operations, 0); + c.insert({_id: 1, a: 1}); + test.length(operations, 0); + + c.resumeObservers(); + test.length(operations, 0); + + + // two modifications become one + c.pauseObservers(); + + c.update({_id: 1}, {a: 2}); + c.update({_id: 1}, {a: 3}); + + c.resumeObservers(); + test.equal(operations.shift(), ['changed', {a: 3}, 0, {a: 1}]); + test.length(operations, 0); + + // test special case for remove({}) + c.pauseObservers(); + test.equal(c.remove({}), 1); + test.length(operations, 0); + c.resumeObservers(); + test.equal(operations.shift(), ['removed', 1, 0, {a: 3}]); + test.length(operations, 0); + + h.stop(); +}); + +Tinytest.add('minimongo - ids matched by selector', test => { + const check = (selector, ids) => { + const idsFromSelector = LocalCollection._idsMatchedBySelector(selector); + // XXX normalize order, in a way that also works for ObjectIDs? + test.equal(idsFromSelector, ids); + }; + check('foo', ['foo']); + check({_id: 'foo'}, ['foo']); + const oid1 = new MongoID.ObjectID(); + check(oid1, [oid1]); + check({_id: oid1}, [oid1]); + check({_id: 'foo', x: 42}, ['foo']); + check({}, null); + check({_id: {$in: ['foo', oid1]}}, ['foo', oid1]); + check({_id: {$ne: 'foo'}}, null); + // not actually valid, but works for now... + check({$and: ['foo']}, ['foo']); + check({$and: [{x: 42}, {_id: oid1}]}, [oid1]); + check({$and: [{x: 42}, {_id: {$in: [oid1]}}]}, [oid1]); +}); + +Tinytest.add('minimongo - reactive stop', test => { + const coll = new LocalCollection(); + coll.insert({_id: 'A'}); + coll.insert({_id: 'B'}); + coll.insert({_id: 'C'}); + + const addBefore = (str, newChar, before) => { + const idx = str.indexOf(before); + if (idx === -1) {return str + newChar;} + return str.slice(0, idx) + newChar + str.slice(idx); + }; + + let x, y; + const sortOrder = ReactiveVar(1); + + const c = Tracker.autorun(() => { + const q = coll.find({}, {sort: {_id: sortOrder.get()}}); + x = ''; + q.observe({ addedAt(doc, atIndex, before) { + x = addBefore(x, doc._id, before); + }}); + y = ''; + q.observeChanges({ addedBefore(id, fields, before) { + y = addBefore(y, id, before); + }}); + }); + + test.equal(x, 'ABC'); + test.equal(y, 'ABC'); + + sortOrder.set(-1); + test.equal(x, 'ABC'); + test.equal(y, 'ABC'); + Tracker.flush(); + test.equal(x, 'CBA'); + test.equal(y, 'CBA'); + + coll.insert({_id: 'D'}); + coll.insert({_id: 'E'}); + test.equal(x, 'EDCBA'); + test.equal(y, 'EDCBA'); + + c.stop(); + // stopping kills the observes immediately + coll.insert({_id: 'F'}); + test.equal(x, 'EDCBA'); + test.equal(y, 'EDCBA'); +}); + +Tinytest.add('minimongo - immediate invalidate', test => { + const coll = new LocalCollection(); + coll.insert({_id: 'A'}); + + // This has two separate findOnes. findOne() uses skip/limit, which means + // that its response to an update() call involves a recompute. We used to have + // a bug where we would first calculate all the calls that need to be + // recomputed, then recompute them one by one, without checking to see if the + // callbacks from recomputing one query stopped the second query, which + // crashed. + const c = Tracker.autorun(() => { + coll.findOne('A'); + coll.findOne('A'); + }); + + coll.update('A', {$set: {x: 42}}); + + c.stop(); +}); + + +Tinytest.add('minimongo - count on cursor with limit', test => { + const coll = new LocalCollection(); + let count; + + coll.insert({_id: 'A'}); + coll.insert({_id: 'B'}); + coll.insert({_id: 'C'}); + coll.insert({_id: 'D'}); + + const c = Tracker.autorun(c => { + const cursor = coll.find({_id: {$exists: true}}, {sort: {_id: 1}, limit: 3}); + count = cursor.count(); + }); + + test.equal(count, 3); + + coll.remove('A'); // still 3 in the collection + Tracker.flush(); + test.equal(count, 3); + + coll.remove('B'); // expect count now 2 + Tracker.flush(); + test.equal(count, 2); + + + coll.insert({_id: 'A'}); // now 3 again + Tracker.flush(); + test.equal(count, 3); + + coll.insert({_id: 'B'}); // now 4 entries, but count should be 3 still + Tracker.flush(); + test.equal(count, 3); + + c.stop(); +}); + +Tinytest.add('minimongo - reactive count with cached cursor', test => { + const coll = new LocalCollection; + const cursor = coll.find({}); + let firstAutorunCount, secondAutorunCount; + Tracker.autorun(() => { + firstAutorunCount = cursor.count(); + }); + Tracker.autorun(() => { + secondAutorunCount = coll.find({}).count(); + }); + test.equal(firstAutorunCount, 0); + test.equal(secondAutorunCount, 0); + coll.insert({i: 1}); + coll.insert({i: 2}); + coll.insert({i: 3}); + Tracker.flush(); + test.equal(firstAutorunCount, 3); + test.equal(secondAutorunCount, 3); +}); + +Tinytest.add('minimongo - $near operator tests', test => { + let coll = new LocalCollection(); + coll.insert({ rest: { loc: [2, 3] } }); + coll.insert({ rest: { loc: [-3, 3] } }); + coll.insert({ rest: { loc: [5, 5] } }); + + test.equal(coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 30 } }).count(), 3); + test.equal(coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 4 } }).count(), 1); + const points = coll.find({ 'rest.loc': { $near: [0, 0], $maxDistance: 6 } }).fetch(); + points.forEach((point, i, points) => { + test.isTrue(!i || distance([0, 0], point.rest.loc) >= distance([0, 0], points[i - 1].rest.loc)); + }); + + function distance(a, b) { + const x = a[0] - b[0]; + const y = a[1] - b[1]; + return Math.sqrt(x * x + y * y); + } + + // GeoJSON tests + coll = new LocalCollection(); + const data = [{ category: 'BURGLARY', descript: 'BURGLARY OF STORE, FORCIBLE ENTRY', address: '100 Block of 10TH ST', location: { type: 'Point', coordinates: [ -122.415449723856, 37.7749518087273 ] } }, + { category: 'WEAPON LAWS', descript: 'POSS OF PROHIBITED WEAPON', address: '900 Block of MINNA ST', location: { type: 'Point', coordinates: [ -122.415386041221, 37.7747879744156 ] } }, + { category: 'LARCENY/THEFT', descript: 'GRAND THEFT OF PROPERTY', address: '900 Block of MINNA ST', location: { type: 'Point', coordinates: [ -122.41538270191, 37.774683628213 ] } }, + { category: 'LARCENY/THEFT', descript: 'PETTY THEFT FROM LOCKED AUTO', address: '900 Block of MINNA ST', location: { type: 'Point', coordinates: [ -122.415396041221, 37.7747879744156 ] } }, + { category: 'OTHER OFFENSES', descript: 'POSSESSION OF BURGLARY TOOLS', address: '900 Block of MINNA ST', location: { type: 'Point', coordinates: [ -122.415386041221, 37.7747879734156 ] } }, + ]; + + data.forEach((x, i) => { coll.insert(Object.assign(x, { x: i })); }); + + const close15 = coll.find({ location: { $near: { + $geometry: { type: 'Point', + coordinates: [-122.4154282, 37.7746115] }, + $maxDistance: 15 } } }).fetch(); + test.length(close15, 1); + test.equal(close15[0].descript, 'GRAND THEFT OF PROPERTY'); + + const close20 = coll.find({ location: { $near: { + $geometry: { type: 'Point', + coordinates: [-122.4154282, 37.7746115] }, + $maxDistance: 20 } } }).fetch(); + test.length(close20, 4); + test.equal(close20[0].descript, 'GRAND THEFT OF PROPERTY'); + test.equal(close20[1].descript, 'PETTY THEFT FROM LOCKED AUTO'); + test.equal(close20[2].descript, 'POSSESSION OF BURGLARY TOOLS'); + test.equal(close20[3].descript, 'POSS OF PROHIBITED WEAPON'); + + // Any combinations of $near with $or/$and/$nor/$not should throw an error + test.throws(() => { + coll.find({ location: { + $not: { + $near: { + $geometry: { + type: 'Point', + coordinates: [-122.4154282, 37.7746115], + }, $maxDistance: 20 } } } }); + }); + test.throws(() => { + coll.find({ + $and: [ { location: { $near: { $geometry: { type: 'Point', coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 20 }}}, + { x: 0 }], + }); + }); + test.throws(() => { + coll.find({ + $or: [ { location: { $near: { $geometry: { type: 'Point', coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 20 }}}, + { x: 0 }], + }); + }); + test.throws(() => { + coll.find({ + $nor: [ { location: { $near: { $geometry: { type: 'Point', coordinates: [-122.4154282, 37.7746115] }, $maxDistance: 1 }}}, + { x: 0 }], + }); + }); + test.throws(() => { + coll.find({ + $and: [{ + $and: [{ + location: { + $near: { + $geometry: { + type: 'Point', + coordinates: [-122.4154282, 37.7746115], + }, + $maxDistance: 1, + }, + }, + }], + }], + }); + }); + + // array tests + coll = new LocalCollection(); + coll.insert({ + _id: 'x', + k: 9, + a: [ + {b: [ + [100, 100], + [1, 1]]}, + {b: [150, 150]}]}); + coll.insert({ + _id: 'y', + k: 9, + a: {b: [5, 5]}}); + const testNear = (near, md, expected) => { + test.equal( + coll.find({'a.b': {$near: near, $maxDistance: md}}).fetch().map(doc => doc._id), + expected); + }; + testNear([149, 149], 4, ['x']); + testNear([149, 149], 1000, ['x', 'y']); + // It's important that we figure out that 'x' is closer than 'y' to [2,2] even + // though the first within-1000 point in 'x' (ie, [100,100]) is farther than + // 'y'. + testNear([2, 2], 1000, ['x', 'y']); + + // issue #3599 + // Ensure that distance is not used as a tie-breaker for sort. + test.equal( + coll.find({'a.b': {$near: [1, 1]}}, {sort: {k: 1}}).fetch().map(doc => doc._id), + ['x', 'y']); + test.equal( + coll.find({'a.b': {$near: [5, 5]}}, {sort: {k: 1}}).fetch().map(doc => doc._id), + ['x', 'y']); + + const operations = []; + const cbs = log_callbacks(operations); + const handle = coll.find({'a.b': {$near: [7, 7]}}).observe(cbs); + + test.length(operations, 2); + test.equal(operations.shift(), ['added', {k: 9, a: {b: [5, 5]}}, 0, null]); + test.equal(operations.shift(), + ['added', {k: 9, a: [{b: [[100, 100], [1, 1]]}, {b: [150, 150]}]}, + 1, null]); + // This needs to be inserted in the MIDDLE of the two existing ones. + coll.insert({a: {b: [3, 3]}}); + test.length(operations, 1); + test.equal(operations.shift(), ['added', {a: {b: [3, 3]}}, 1, 'x']); + + handle.stop(); +}); + +// issue #2077 +Tinytest.add('minimongo - $near and $geometry for legacy coordinates', test => { + const coll = new LocalCollection(); + + coll.insert({ + loc: { + x: 1, + y: 1, + }, + }); + coll.insert({ + loc: [-1, -1], + }); + coll.insert({ + loc: [40, -10], + }); + coll.insert({ + loc: { + x: -10, + y: 40, + }, + }); + + test.equal(coll.find({ loc: { $near: [0, 0], $maxDistance: 4 } }).count(), 2); + test.equal(coll.find({ loc: { $near: {$geometry: {type: 'Point', coordinates: [0, 0]}}} }).count(), 4); + test.equal(coll.find({ loc: { $near: {$geometry: {type: 'Point', coordinates: [0, 0]}, $maxDistance: 200000}}}).count(), 2); +}); + +// Regression test for #4377. Previously, "replace" updates didn't clone the +// argument. +Tinytest.add('minimongo - update should clone', test => { + const x = []; + const coll = new LocalCollection; + const id = coll.insert({}); + coll.update(id, {x}); + x.push(1); + test.equal(coll.findOne(id), {_id: id, x: []}); +}); + +// See #2275. +Tinytest.add('minimongo - fetch in observe', test => { + const coll = new LocalCollection; + let callbackInvoked = false; + const observe = coll.find().observeChanges({ + added(id, fields) { + callbackInvoked = true; + test.equal(fields, {foo: 1}); + const doc = coll.findOne({foo: 1}); + test.isTrue(doc); + test.equal(doc.foo, 1); + }, + }); + test.isFalse(callbackInvoked); + const computation = Tracker.autorun(computation => { + if (computation.firstRun) { + coll.insert({foo: 1}); + } + }); + test.isTrue(callbackInvoked); + observe.stop(); + computation.stop(); +}); + +// See #2254 +Tinytest.add('minimongo - fine-grained reactivity of observe with fields projection', test => { + const X = new LocalCollection; + const id = 'asdf'; + X.insert({_id: id, foo: {bar: 123}}); + + let callbackInvoked = false; + const obs = X.find(id, {fields: {'foo.bar': 1}}).observeChanges({ + changed(id, fields) { + callbackInvoked = true; + }, + }); + + test.isFalse(callbackInvoked); + X.update(id, {$set: {'foo.baz': 456}}); + test.isFalse(callbackInvoked); + + obs.stop(); +}); +Tinytest.add('minimongo - fine-grained reactivity of query with fields projection', test => { + const X = new LocalCollection; + const id = 'asdf'; + X.insert({_id: id, foo: {bar: 123}}); + + let callbackInvoked = false; + const computation = Tracker.autorun(() => { + callbackInvoked = true; + return X.findOne(id, { fields: { 'foo.bar': 1 } }); + }); + test.isTrue(callbackInvoked); + callbackInvoked = false; + X.update(id, {$set: {'foo.baz': 456}}); + test.isFalse(callbackInvoked); + X.update(id, {$set: {'foo.bar': 124}}); + Tracker.flush(); + test.isTrue(callbackInvoked); + + computation.stop(); +}); + +// Tests that the logic in `LocalCollection.prototype.update` +// correctly deals with count() on a cursor with skip or limit (since +// then the result set is an IdMap, not an array) +Tinytest.add('minimongo - reactive skip/limit count while updating', test => { + const X = new LocalCollection; + let count = -1; + + const c = Tracker.autorun(() => { + count = X.find({}, {skip: 1, limit: 1}).count(); + }); + + test.equal(count, 0); + + X.insert({}); + Tracker.flush({_throwFirstError: true}); + test.equal(count, 0); + + X.insert({}); + Tracker.flush({_throwFirstError: true}); + test.equal(count, 1); + + X.update({}, {$set: {foo: 1}}); + Tracker.flush({_throwFirstError: true}); + test.equal(count, 1); + + // Make sure a second update also works + X.update({}, {$set: {foo: 2}}); + Tracker.flush({_throwFirstError: true}); + test.equal(count, 1); + + c.stop(); +}); + +// Makes sure inserts cannot be performed using field names that have +// Mongo restricted characters in them ('.', '$', '\0'): +// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names +Tinytest.add('minimongo - cannot insert using invalid field names', test => { + const collection = new LocalCollection(); + + // Quick test to make sure non-dot field inserts are working + collection.insert({ a: 'b' }); + + // Quick test to make sure field values with dots are allowed + collection.insert({ a: 'b.c' }); + + // Verify top level dot-field inserts are prohibited + ['a.b', '.b', 'a.', 'a.b.c'].forEach((field) => { + test.throws(() => { + collection.insert({ [field]: 'c' }); + }, `Key ${field} must not contain '.'`); + }); + + // Verify nested dot-field inserts are prohibited + test.throws(() => { + collection.insert({ a: { b: { 'c.d': 'e' } } }); + }, "Key c.d must not contain '.'"); + + // Verify field names starting with $ are prohibited + test.throws(() => { + collection.insert({ $a: 'b' }); + }, "Key $a must not start with '$'"); + + // Verify nested field names starting with $ are prohibited + test.throws(() => { + collection.insert({ a: { b: { $c: 'd' } } }); + }, "Key $c must not start with '$'"); + + // Verify top level fields with null characters are prohibited + ['\0a', 'a\0', 'a\0b', '\u0000a', 'a\u0000', 'a\u0000b'].forEach((field) => { + test.throws(() => { + collection.insert({ [field]: 'c' }); + }, `Key ${field} must not contain null bytes`); + }); + + // Verify nested field names with null characters are prohibited + test.throws(() => { + collection.insert({ a: { b: { '\0c': 'd' } } }); + }, 'Key \0c must not contain null bytes'); +}); + +// Makes sure $set's cannot be performed using null bytes +// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names +Tinytest.add('minimongo - cannot $set with null bytes', test => { + const collection = new LocalCollection(); + + // Quick test to make sure non-null byte $set's are working + const id = collection.insert({ a: 'b', c: 'd' }); + collection.update({ _id: id }, { $set: { e: 'f' } }); + + // Verify $set's with null bytes throw an exception + test.throws(() => { + collection.update({ _id: id }, { $set: { '\0a': 'b' } }); + }, 'Key \0a must not contain null bytes'); +}); + +// Makes sure $rename's cannot be performed using null bytes +// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names +Tinytest.add('minimongo - cannot $rename with null bytes', test => { + const collection = new LocalCollection(); + + // Quick test to make sure non-null byte $rename's are working + let id = collection.insert({ a: 'b', c: 'd' }); + collection.update({ _id: id }, { $rename: { a: 'a1', c: 'c1' } }); + + // Verify $rename's with null bytes throw an exception + collection.remove({}); + id = collection.insert({ a: 'b', c: 'd' }); + test.throws(() => { + collection.update({ _id: id }, { $rename: { a: '\0a', c: 'c\0' } }); + }, "The 'to' field for $rename cannot contain an embedded null byte"); +}); diff --git a/packages/minimongo/minimongo_tests_server.js b/packages/minimongo/minimongo_tests_server.js new file mode 100644 index 00000000000..eccb8a980ad --- /dev/null +++ b/packages/minimongo/minimongo_tests_server.js @@ -0,0 +1,569 @@ +Tinytest.add('minimongo - modifier affects selector', test => { + function testSelectorPaths(sel, paths, desc) { + const matcher = new Minimongo.Matcher(sel); + test.equal(matcher._getPaths(), paths, desc); + } + + testSelectorPaths({ + foo: { + bar: 3, + baz: 42, + }, + }, ['foo'], 'literal'); + + testSelectorPaths({ + foo: 42, + bar: 33, + }, ['foo', 'bar'], 'literal'); + + testSelectorPaths({ + foo: [ 'something' ], + bar: 'asdf', + }, ['foo', 'bar'], 'literal'); + + testSelectorPaths({ + a: { $lt: 3 }, + b: 'you know, literal', + 'path.is.complicated': { $not: { $regex: 'acme.*corp' } }, + }, ['a', 'b', 'path.is.complicated'], 'literal + operators'); + + testSelectorPaths({ + $or: [{ 'a.b': 1 }, { 'a.b.c': { $lt: 22 } }, + {$and: [{ 'x.d': { $ne: 5, $gte: 433 } }, { 'a.b': 234 }]}], + }, ['a.b', 'a.b.c', 'x.d'], 'group operators + duplicates'); + + // When top-level value is an object, it is treated as a literal, + // so when you query col.find({ a: { foo: 1, bar: 2 } }) + // it doesn't mean you are looking for anything that has 'a.foo' to be 1 and + // 'a.bar' to be 2, instead you are looking for 'a' to be exatly that object + // with exatly that order of keys. { a: { foo: 1, bar: 2, baz: 3 } } wouldn't + // match it. That's why in this selector 'a' would be important key, not a.foo + // and a.bar. + testSelectorPaths({ + a: { + foo: 1, + bar: 2, + }, + 'b.c': { + literal: 'object', + but: "we still observe any changes in 'b.c'", + }, + }, ['a', 'b.c'], 'literal object'); + + // Note that a and b do NOT end up in the path list, but x and y both do. + testSelectorPaths({ + $or: [ + {x: {$elemMatch: {a: 5}}}, + {y: {$elemMatch: {b: 7}}}, + ], + }, ['x', 'y'], '$or and elemMatch'); + + function testSelectorAffectedByModifier(sel, mod, yes, desc) { + const matcher = new Minimongo.Matcher(sel); + test.equal(matcher.affectedByModifier(mod), yes, desc); + } + + function affected(sel, mod, desc) { + testSelectorAffectedByModifier(sel, mod, true, desc); + } + function notAffected(sel, mod, desc) { + testSelectorAffectedByModifier(sel, mod, false, desc); + } + + notAffected({ foo: 0 }, { $set: { bar: 1 } }, 'simplest'); + affected({ foo: 0 }, { $set: { foo: 1 } }, 'simplest'); + affected({ foo: 0 }, { $set: { 'foo.bar': 1 } }, 'simplest'); + notAffected({ 'foo.bar': 0 }, { $set: { 'foo.baz': 1 } }, 'simplest'); + affected({ 'foo.bar': 0 }, { $set: { 'foo.1': 1 } }, 'simplest'); + affected({ 'foo.bar': 0 }, { $set: { 'foo.2.bar': 1 } }, 'simplest'); + + notAffected({ foo: 0 }, { $set: { foobaz: 1 } }, 'correct prefix check'); + notAffected({ foobar: 0 }, { $unset: { foo: 1 } }, 'correct prefix check'); + notAffected({ 'foo.bar': 0 }, { $unset: { foob: 1 } }, 'correct prefix check'); + + notAffected({ 'foo.Infinity.x': 0 }, { $unset: { 'foo.x': 1 } }, 'we convert integer fields correctly'); + notAffected({ 'foo.1e3.x': 0 }, { $unset: { 'foo.x': 1 } }, 'we convert integer fields correctly'); + + affected({ 'foo.3.bar': 0 }, { $set: { 'foo.3.bar': 1 } }, 'observe for an array element'); + + notAffected({ 'foo.4.bar.baz': 0 }, { $unset: { 'foo.3.bar': 1 } }, 'delicate work with numeric fields in selector'); + notAffected({ 'foo.4.bar.baz': 0 }, { $unset: { 'foo.bar': 1 } }, 'delicate work with numeric fields in selector'); + affected({ 'foo.4.bar.baz': 0 }, { $unset: { 'foo.4.bar': 1 } }, 'delicate work with numeric fields in selector'); + affected({ 'foo.bar.baz': 0 }, { $unset: { 'foo.3.bar': 1 } }, 'delicate work with numeric fields in selector'); + + affected({ 'foo.0.bar': 0 }, { $set: { 'foo.0.0.bar': 1 } }, 'delicate work with nested arrays and selectors by indecies'); + + affected({foo: {$elemMatch: {bar: 5}}}, {$set: {'foo.4.bar': 5}}, '$elemMatch'); +}); + +Tinytest.add('minimongo - selector and projection combination', test => { + function testSelProjectionComb(sel, proj, expected, desc) { + const matcher = new Minimongo.Matcher(sel); + test.equal(matcher.combineIntoProjection(proj), expected, desc); + } + + // Test with inclusive projection + testSelProjectionComb({ a: 1, b: 2 }, { b: 1, c: 1, d: 1 }, { a: true, b: true, c: true, d: true }, 'simplest incl'); + testSelProjectionComb({ $or: [{ a: 1234, e: {$lt: 5} }], b: 2 }, { b: 1, c: 1, d: 1 }, { a: true, b: true, c: true, d: true, e: true }, 'simplest incl, branching'); + testSelProjectionComb({ + 'a.b': { $lt: 3 }, + 'y.0': -1, + 'a.c': 15, + }, { + d: 1, + z: 1, + }, { + 'a.b': true, + y: true, + 'a.c': true, + d: true, + z: true, + }, 'multikey paths in selector - incl'); + + testSelProjectionComb({ + foo: 1234, + $and: [{ k: -1 }, { $or: [{ b: 15 }] }], + }, { + 'foo.bar': 1, + 'foo.zzz': 1, + 'b.asdf': 1, + }, { + foo: true, + b: true, + k: true, + }, 'multikey paths in fields - incl'); + + testSelProjectionComb({ + 'a.b.c': 123, + 'a.b.d': 321, + 'b.c.0': 111, + 'a.e': 12345, + }, { + 'a.b.z': 1, + 'a.b.d.g': 1, + 'c.c.c': 1, + }, { + 'a.b.c': true, + 'a.b.d': true, + 'a.b.z': true, + 'b.c': true, + 'a.e': true, + 'c.c.c': true, + }, 'multikey both paths - incl'); + + testSelProjectionComb({ + 'a.b.c.d': 123, + 'a.b1.c.d': 421, + 'a.b.c.e': 111, + }, { + 'a.b': 1, + }, { + 'a.b': true, + 'a.b1.c.d': true, + }, 'shadowing one another - incl'); + + testSelProjectionComb({ + 'a.b': 123, + 'foo.bar': false, + }, { + 'a.b.c.d': 1, + foo: 1, + }, { + 'a.b': true, + foo: true, + }, 'shadowing one another - incl'); + + testSelProjectionComb({ + 'a.b.c': 1, + }, { + 'a.b.c': 1, + }, { + 'a.b.c': true, + }, 'same paths - incl'); + + testSelProjectionComb({ + 'x.4.y': 42, + 'z.0.1': 33, + }, { + 'x.x': 1, + }, { + 'x.x': true, + 'x.y': true, + z: true, + }, 'numbered keys in selector - incl'); + + testSelProjectionComb({ + 'a.b.c': 42, + $where() { return true; }, + }, { + 'a.b': 1, + 'z.z': 1, + }, {}, '$where in the selector - incl'); + + testSelProjectionComb({ + $or: [ + {'a.b.c': 42}, + {$where() { return true; } }, + ], + }, { + 'a.b': 1, + 'z.z': 1, + }, {}, '$where in the selector - incl'); + + // Test with exclusive projection + testSelProjectionComb({ a: 1, b: 2 }, { b: 0, c: 0, d: 0 }, { c: false, d: false }, 'simplest excl'); + testSelProjectionComb({ $or: [{ a: 1234, e: {$lt: 5} }], b: 2 }, { b: 0, c: 0, d: 0 }, { c: false, d: false }, 'simplest excl, branching'); + testSelProjectionComb({ + 'a.b': { $lt: 3 }, + 'y.0': -1, + 'a.c': 15, + }, { + d: 0, + z: 0, + }, { + d: false, + z: false, + }, 'multikey paths in selector - excl'); + + testSelProjectionComb({ + foo: 1234, + $and: [{ k: -1 }, { $or: [{ b: 15 }] }], + }, { + 'foo.bar': 0, + 'foo.zzz': 0, + 'b.asdf': 0, + }, { + }, 'multikey paths in fields - excl'); + + testSelProjectionComb({ + 'a.b.c': 123, + 'a.b.d': 321, + 'b.c.0': 111, + 'a.e': 12345, + }, { + 'a.b.z': 0, + 'a.b.d.g': 0, + 'c.c.c': 0, + }, { + 'a.b.z': false, + 'c.c.c': false, + }, 'multikey both paths - excl'); + + testSelProjectionComb({ + 'a.b.c.d': 123, + 'a.b1.c.d': 421, + 'a.b.c.e': 111, + }, { + 'a.b': 0, + }, { + }, 'shadowing one another - excl'); + + testSelProjectionComb({ + 'a.b': 123, + 'foo.bar': false, + }, { + 'a.b.c.d': 0, + foo: 0, + }, { + }, 'shadowing one another - excl'); + + testSelProjectionComb({ + 'a.b.c': 1, + }, { + 'a.b.c': 0, + }, { + }, 'same paths - excl'); + + testSelProjectionComb({ + 'a.b': 123, + 'a.c.d': 222, + ddd: 123, + }, { + 'a.b': 0, + 'a.c.e': 0, + asdf: 0, + }, { + 'a.c.e': false, + asdf: false, + }, 'intercept the selector path - excl'); + + testSelProjectionComb({ + 'a.b.c': 14, + }, { + 'a.b.d': 0, + }, { + 'a.b.d': false, + }, 'different branches - excl'); + + testSelProjectionComb({ + 'a.b.c.d': '124', + 'foo.bar.baz.que': 'some value', + }, { + 'a.b.c.d.e': 0, + 'foo.bar': 0, + }, { + }, 'excl on incl paths - excl'); + + testSelProjectionComb({ + 'x.4.y': 42, + 'z.0.1': 33, + }, { + 'x.x': 0, + 'x.y': 0, + }, { + 'x.x': false, + }, 'numbered keys in selector - excl'); + + testSelProjectionComb({ + 'a.b.c': 42, + $where() { return true; }, + }, { + 'a.b': 0, + 'z.z': 0, + }, {}, '$where in the selector - excl'); + + testSelProjectionComb({ + $or: [ + {'a.b.c': 42}, + {$where() { return true; } }, + ], + }, { + 'a.b': 0, + 'z.z': 0, + }, {}, '$where in the selector - excl'); +}); + +Tinytest.add('minimongo - sorter and projection combination', test => { + function testSorterProjectionComb(sortSpec, proj, expected, desc) { + const sorter = new Minimongo.Sorter(sortSpec); + test.equal(sorter.combineIntoProjection(proj), expected, desc); + } + + // Test with inclusive projection + testSorterProjectionComb({ a: 1, b: 1 }, { b: 1, c: 1, d: 1 }, { a: true, b: true, c: true, d: true }, 'simplest incl'); + testSorterProjectionComb({ a: 1, b: -1 }, { b: 1, c: 1, d: 1 }, { a: true, b: true, c: true, d: true }, 'simplest incl'); + testSorterProjectionComb({ 'a.c': 1 }, { b: 1 }, { 'a.c': true, b: true }, 'dot path incl'); + testSorterProjectionComb({ 'a.1.c': 1 }, { b: 1 }, { 'a.c': true, b: true }, 'dot num path incl'); + testSorterProjectionComb({ 'a.1.c': 1 }, { b: 1, a: 1 }, { a: true, b: true }, 'dot num path incl overlap'); + testSorterProjectionComb({ 'a.1.c': 1, 'a.2.b': -1 }, { b: 1 }, { 'a.c': true, 'a.b': true, b: true }, 'dot num path incl'); + testSorterProjectionComb({ 'a.1.c': 1, 'a.2.b': -1 }, {}, {}, 'dot num path with empty incl'); + + // Test with exclusive projection + testSorterProjectionComb({ a: 1, b: 1 }, { b: 0, c: 0, d: 0 }, { c: false, d: false }, 'simplest excl'); + testSorterProjectionComb({ a: 1, b: -1 }, { b: 0, c: 0, d: 0 }, { c: false, d: false }, 'simplest excl'); + testSorterProjectionComb({ 'a.c': 1 }, { b: 0 }, { b: false }, 'dot path excl'); + testSorterProjectionComb({ 'a.1.c': 1 }, { b: 0 }, { b: false }, 'dot num path excl'); + testSorterProjectionComb({ 'a.1.c': 1 }, { b: 0, a: 0 }, { b: false }, 'dot num path excl overlap'); + testSorterProjectionComb({ 'a.1.c': 1, 'a.2.b': -1 }, { b: 0 }, { b: false }, 'dot num path excl'); +}); + + +((() => { + // TODO: Tests for "can selector become true by modifier" are incomplete, + // absent or test the functionality of "not ideal" implementation (test checks + // that certain case always returns true as implementation is incomplete) + // - tests with $and/$or/$nor/$not branches (are absent) + // - more tests with arrays fields and numeric keys (incomplete and test "not + // ideal" implementation) + // - tests when numeric keys actually mean numeric keys, not array indexes + // (are absent) + // - tests with $-operators in the selector (are incomplete and test "not + // ideal" implementation) + // * gives up on $-operators with non-scalar values ({$ne: {x: 1}}) + // * analyses $in + // * analyses $nin/$ne + // * analyses $gt, $gte, $lt, $lte + // * gives up on a combination of $gt/$gte/$lt/$lte and $ne/$nin + // * doesn't support $eq properly + + let test = null; // set this global in the beginning of every test + // T - should return true + // F - should return false + const oneTest = (sel, mod, expected, desc) => { + const matcher = new Minimongo.Matcher(sel); + test.equal(matcher.canBecomeTrueByModifier(mod), expected, desc); + }; + function T(sel, mod, desc) { + oneTest(sel, mod, true, desc); + } + function F(sel, mod, desc) { + oneTest(sel, mod, false, desc); + } + + Tinytest.add('minimongo - can selector become true by modifier - literals (structured tests)', t => { + test = t; + + const selector = { + 'a.b.c': 2, + 'foo.bar': { + z: { y: 1 }, + }, + 'foo.baz': [ {ans: 42}, 'string', false, undefined ], + 'empty.field': null, + }; + + T(selector, {$set: { 'a.b.c': 2 }}); + F(selector, {$unset: { a: 1 }}); + F(selector, {$unset: { 'a.b': 1 }}); + F(selector, {$unset: { 'a.b.c': 1 }}); + T(selector, {$set: { 'a.b': { c: 2 } }}); + F(selector, {$set: { 'a.b': {} }}); + T(selector, {$set: { 'a.b': { c: 2, x: 5 } }}); + F(selector, {$set: { 'a.b.c.k': 3 }}); + F(selector, {$set: { 'a.b.c.k': {} }}); + + F(selector, {$unset: { foo: 1 }}); + F(selector, {$unset: { 'foo.bar': 1 }}); + F(selector, {$unset: { 'foo.bar.z': 1 }}); + F(selector, {$unset: { 'foo.bar.z.y': 1 }}); + F(selector, {$set: { 'foo.bar.x': 1 }}); + F(selector, {$set: { 'foo.bar': {} }}); + F(selector, {$set: { 'foo.bar': 3 }}); + T(selector, {$set: { 'foo.bar': { z: { y: 1 } } }}); + T(selector, {$set: { 'foo.bar.z': { y: 1 } }}); + T(selector, {$set: { 'foo.bar.z.y': 1 }}); + + F(selector, {$set: { 'empty.field': {} }}); + T(selector, {$set: { empty: {} }}); + T(selector, {$set: { 'empty.field': null }}); + T(selector, {$set: { 'empty.field': undefined }}); + F(selector, {$set: { 'empty.field.a': 3 }}); + }); + + Tinytest.add('minimongo - can selector become true by modifier - literals (adhoc tests)', t => { + test = t; + T({x: 1}, {$set: {x: 1}}, 'simple set scalar'); + T({x: 'a'}, {$set: {x: 'a'}}, 'simple set scalar'); + T({x: false}, {$set: {x: false}}, 'simple set scalar'); + F({x: true}, {$set: {x: false}}, 'simple set scalar'); + F({x: 2}, {$set: {x: 3}}, 'simple set scalar'); + + F({'foo.bar.baz': 1, x: 1}, {$unset: {'foo.bar.baz': 1}, $set: {x: 1}}, 'simple unset of the interesting path'); + F({'foo.bar.baz': 1, x: 1}, {$unset: {'foo.bar': 1}, $set: {x: 1}}, 'simple unset of the interesting path prefix'); + F({'foo.bar.baz': 1, x: 1}, {$unset: {foo: 1}, $set: {x: 1}}, 'simple unset of the interesting path prefix'); + F({'foo.bar.baz': 1}, {$unset: {'foo.baz': 1}}, 'simple unset of the interesting path prefix'); + F({'foo.bar.baz': 1}, {$unset: {'foo.bar.bar': 1}}, 'simple unset of the interesting path prefix'); + }); + + Tinytest.add('minimongo - can selector become true by modifier - regexps', t => { + test = t; + + // Regexp + T({ 'foo.bar': /^[0-9]+$/i }, { $set: {'foo.bar': '01233'} }, 'set of regexp'); + // XXX this test should be False, should be fixed within improved implementation + T({ 'foo.bar': /^[0-9]+$/i, x: 1 }, { $set: {'foo.bar': '0a1233', x: 1} }, 'set of regexp'); + // XXX this test should be False, should be fixed within improved implementation + T({ 'foo.bar': /^[0-9]+$/i, x: 1 }, { $unset: {'foo.bar': 1}, $set: { x: 1 } }, 'unset of regexp'); + T({ 'foo.bar': /^[0-9]+$/i, x: 1 }, { $set: { x: 1 } }, "don't touch regexp"); + }); + + Tinytest.add('minimongo - can selector become true by modifier - undefined/null', t => { + test = t; + // Nulls / Undefined + T({ 'foo.bar': null }, {$set: {'foo.bar': null}}, 'set of null looking for null'); + T({ 'foo.bar': null }, {$set: {'foo.bar': undefined}}, 'set of undefined looking for null'); + T({ 'foo.bar': undefined }, {$set: {'foo.bar': null}}, 'set of null looking for undefined'); + T({ 'foo.bar': undefined }, {$set: {'foo.bar': undefined}}, 'set of undefined looking for undefined'); + T({ 'foo.bar': null }, {$set: {foo: null}}, 'set of null of parent path looking for null'); + F({ 'foo.bar': null }, {$set: {'foo.bar.baz': null}}, 'set of null of different path looking for null'); + T({ 'foo.bar': null }, { $unset: { foo: 1 } }, 'unset the parent'); + T({ 'foo.bar': null }, { $unset: { 'foo.bar': 1 } }, 'unset tracked path'); + T({ 'foo.bar': null }, { $set: { foo: 3 } }, 'set the parent'); + T({ 'foo.bar': null }, { $set: { foo: {baz: 1} } }, 'set the parent'); + }); + + Tinytest.add('minimongo - can selector become true by modifier - literals with arrays', t => { + test = t; + // These tests are incomplete and in theory they all should return true as we + // don't support any case with numeric fields yet. + T({'a.1.b': 1, x: 1}, {$unset: {'a.1.b': 1}, $set: {x: 1}}, "unset of array element's field with exactly the same index as selector"); + F({'a.2.b': 1}, {$unset: {'a.1.b': 1}}, "unset of array element's field with different index as selector"); + // This is false, because if you are looking for array but in reality it is an + // object, it just can't get to true. + F({'a.2.b': 1}, {$unset: {'a.b': 1}}, 'unset of field while selector is looking for index'); + T({ 'foo.bar': null }, {$set: {'foo.1.bar': null}}, "set array's element's field to null looking for null"); + T({ 'foo.bar': null }, {$set: {'foo.0.bar': 1, 'foo.1.bar': null}}, "set array's element's field to null looking for null"); + // This is false, because there may remain other array elements that match + // but we modified this test as we don't support this case yet + T({'a.b': 1}, {$unset: {'a.1.b': 1}}, "unset of array element's field"); + }); + + Tinytest.add('minimongo - can selector become true by modifier - set an object literal whose fields are selected', t => { + test = t; + T({ 'a.b.c': 1 }, { $set: { 'a.b': { c: 1 } } }, 'a simple scalar selector and simple set'); + F({ 'a.b.c': 1 }, { $set: { 'a.b': { c: 2 } } }, 'a simple scalar selector and simple set to false'); + F({ 'a.b.c': 1 }, { $set: { 'a.b': { d: 1 } } }, 'a simple scalar selector and simple set a wrong literal'); + F({ 'a.b.c': 1 }, { $set: { 'a.b': 222 } }, 'a simple scalar selector and simple set a wrong type'); + }); + + Tinytest.add('minimongo - can selector become true by modifier - $-scalar selectors and simple tests', t => { + test = t; + T({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { c: 4 } } }, 'nested $lt'); + F({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { c: 5 } } }, 'nested $lt'); + F({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { c: 6 } } }, 'nested $lt'); + F({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b.d': 7 } }, "nested $lt, the change doesn't matter"); + F({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { d: 7 } } }, 'nested $lt, the key disappears'); + T({ 'a.b.c': { $lt: 5 } }, { $set: { 'a.b': { d: 7, c: -1 } } }, 'nested $lt'); + F({ a: { $lt: 10, $gt: 3 } }, { $unset: { a: 1 } }, 'unset $lt'); + T({ a: { $lt: 10, $gt: 3 } }, { $set: { a: 4 } }, 'set between x and y'); + F({ a: { $lt: 10, $gt: 3 } }, { $set: { a: 3 } }, 'set between x and y'); + F({ a: { $lt: 10, $gt: 3 } }, { $set: { a: 10 } }, 'set between x and y'); + F({ a: { $gt: 10, $lt: 3 } }, { $set: { a: 9 } }, 'impossible statement'); + T({ a: { $lte: 10, $gte: 3 } }, { $set: { a: 3 } }, 'set between x and y'); + T({ a: { $lte: 10, $gte: 3 } }, { $set: { a: 10 } }, 'set between x and y'); + F({ a: { $lte: 10, $gte: 3 } }, { $set: { a: -10 } }, 'set between x and y'); + T({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 } }, { $set: { a: 4 } }, 'set between x and y'); + F({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 } }, { $set: { a: 3 } }, 'set between x and y'); + F({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 } }, { $set: { a: 10 } }, 'set between x and y'); + F({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 } }, { $set: { a: Infinity } }, 'set between x and y'); + T({ a: { $lte: 10, $gte: 3, $gt: 3, $lt: 10 }, x: 1 }, { $set: { x: 1 } }, 'set between x and y - dummy'); + F({ a: { $lte: 10, $gte: 13, $gt: 3, $lt: 9 }, x: 1 }, { $set: { x: 1 } }, 'set between x and y - dummy - impossible'); + F({ a: { $lte: 10 } }, { $set: { a: Infinity } }, 'Infinity <= 10?'); + T({ a: { $lte: 10 } }, { $set: { a: -Infinity } }, '-Infinity <= 10?'); + // XXX is this sufficient? + T({ a: { $gt: 9.99999999999999, $lt: 10 }, x: 1 }, { $set: { x: 1 } }, 'very close $gt and $lt'); + // XXX this test should be F, but since it is so hard to be precise in + // floating point math, the current implementation falls back to T + T({ a: { $gt: 9.999999999999999, $lt: 10 }, x: 1 }, { $set: { x: 1 } }, 'very close $gt and $lt'); + T({ a: { $eq: 5 } }, { $set: { a: 5 } }, 'set of $eq'); + T({ a: { $eq: 5 }, b: { $eq: 7 } }, { $set: { a: 5 } }, 'set of $eq with other $eq'); + F({ a: { $eq: 5 } }, { $set: { a: 4 } }, 'set below of $eq'); + F({ a: { $eq: 5 } }, { $set: { a: 6 } }, 'set above of $eq'); + T({ a: { $ne: 5 } }, { $unset: { a: 1 } }, 'unset of $ne'); + T({ a: { $ne: 5 } }, { $set: { a: 1 } }, 'set of $ne'); + T({ a: { $ne: 'some string' }, x: 1 }, { $set: { x: 1 } }, '$ne dummy'); + T({ a: { $ne: true }, x: 1 }, { $set: { x: 1 } }, '$ne dummy'); + T({ a: { $ne: false }, x: 1 }, { $set: { x: 1 } }, '$ne dummy'); + T({ a: { $ne: null }, x: 1 }, { $set: { x: 1 } }, '$ne dummy'); + T({ a: { $ne: Infinity }, x: 1 }, { $set: { x: 1 } }, '$ne dummy'); + T({ a: { $ne: 5 } }, { $set: { a: -10 } }, 'set of $ne'); + T({ a: { $in: [1, 3, 5, 7] } }, { $set: { a: 5 } }, '$in checks'); + F({ a: { $in: [1, 3, 5, 7] } }, { $set: { a: -5 } }, '$in checks'); + T({ a: { $in: [1, 3, 5, 7], $gt: 6 }, x: 1 }, { $set: { x: 1 } }, '$in combination with $gt'); + F({ a: { $lte: 10, $gte: 3 } }, { $set: { 'a.b': -10 } }, 'sel between x and y, set its subfield'); + F({ b: { $in: [1, 3, 5, 7] } }, { $set: { 'b.c': 2 } }, 'sel $in, set subfield'); + T({ b: { $in: [1, 3, 5, 7] } }, { $set: { 'bd.c': 2, b: 3 } }, 'sel $in, set similar subfield'); + F({ 'b.c': { $in: [1, 3, 5, 7] } }, { $set: { b: 2 } }, 'sel subfield of set scalar'); + // If modifier tries to set a sub-field of a path expected to be a scalar. + F({ 'a.b': { $gt: 5, $lt: 7}, x: 1 }, { $set: { 'a.b.c': 3, x: 1 } }, 'set sub-field of $gt,$lt operator (scalar expected)'); + F({ 'a.b': { $gt: 5, $lt: 7}, x: 1 }, { $set: { x: 1 }, $unset: { 'a.b.c': 1 } }, 'unset sub-field of $gt,$lt operator (scalar expected)'); + }); + + Tinytest.add('minimongo - can selector become true by modifier - $-nonscalar selectors and simple tests', t => { + test = t; + T({ a: { $eq: { x: 5 } } }, { $set: { 'a.x': 5 } }, 'set of $eq'); + // XXX this test should be F, but it is not implemented yet + T({ a: { $eq: { x: 5 } } }, { $set: { 'a.x': 4 } }, 'set of $eq'); + // XXX this test should be F, but it is not implemented yet + T({ a: { $eq: { x: 5 } } }, { $set: { 'a.y': 4 } }, 'set of $eq'); + T({ a: { $ne: { x: 5 } } }, { $set: { 'a.x': 3 } }, 'set of $ne'); + // XXX this test should be F, but it is not implemented yet + T({ a: { $ne: { x: 5 } } }, { $set: { 'a.x': 5 } }, 'set of $ne'); + T({ a: { $in: [{ b: 1 }, { b: 3 }] } }, { $set: { a: { b: 3 } } }, '$in checks'); + // XXX this test should be F, but it is not implemented yet + T({ a: { $in: [{ b: 1 }, { b: 3 }] } }, { $set: { a: { v: 3 } } }, '$in checks'); + T({ a: { $ne: { a: 2 } }, x: 1 }, { $set: { x: 1 } }, '$ne dummy'); + // XXX this test should be F, but it is not implemented yet + T({ a: { $ne: { a: 2 } } }, { $set: { a: { a: 2 } } }, '$ne object'); + }); +}))(); diff --git a/packages/minimongo/modify.js b/packages/minimongo/modify.js deleted file mode 100644 index 8ebd7a6a05e..00000000000 --- a/packages/minimongo/modify.js +++ /dev/null @@ -1,503 +0,0 @@ -import { assertHasValidFieldNames, assertIsValidFieldName } from './validation.js'; - -// XXX need a strategy for passing the binding of $ into this -// function, from the compiled selector -// -// maybe just {key.up.to.just.before.dollarsign: array_index} -// -// XXX atomicity: if one modification fails, do we roll back the whole -// change? -// -// options: -// - isInsert is set when _modify is being called to compute the document to -// insert as part of an upsert operation. We use this primarily to figure -// out when to set the fields in $setOnInsert, if present. -LocalCollection._modify = function (doc, mod, options) { - options = options || {}; - if (!isPlainObject(mod)) - throw MinimongoError("Modifier must be an object"); - - // Make sure the caller can't mutate our data structures. - mod = EJSON.clone(mod); - - var isModifier = isOperatorObject(mod); - - var newDoc; - - if (!isModifier) { - if (mod._id && doc._id && !EJSON.equals(doc._id, mod._id)) { - throw MinimongoError(`The _id field cannot be changed from {_id: "${doc._id}"} to {_id: "${mod._id}"}`); - } - - // replace the whole document - assertHasValidFieldNames(mod); - newDoc = mod; - } else { - // apply modifiers to the doc. - newDoc = EJSON.clone(doc); - - _.each(mod, function (operand, op) { - var modFunc = MODIFIERS[op]; - // Treat $setOnInsert as $set if this is an insert. - if (options.isInsert && op === '$setOnInsert') - modFunc = MODIFIERS['$set']; - if (!modFunc) - throw MinimongoError("Invalid modifier specified " + op); - _.each(operand, function (arg, keypath) { - if (keypath === '') { - throw MinimongoError("An empty update path is not valid."); - } - - var keyparts = keypath.split('.'); - - if (! _.all(keyparts, _.identity)) { - throw MinimongoError( - "The update path '" + keypath + - "' contains an empty field name, which is not allowed."); - } - - var noCreate = _.has(NO_CREATE_MODIFIERS, op); - var forbidArray = (op === "$rename"); - var target = findModTarget(newDoc, keyparts, { - noCreate: NO_CREATE_MODIFIERS[op], - forbidArray: (op === "$rename"), - arrayIndices: options.arrayIndices - }); - var field = keyparts.pop(); - modFunc(target, field, arg, keypath, newDoc); - }); - }); - - if (doc._id && !EJSON.equals(doc._id, newDoc._id)) { - throw MinimongoError('After applying the update to the document {_id: ' + - `"${doc._id}" , ...}, the (immutable) field '_id' was found to have` + - ` been altered to _id: "${newDoc._id}"`); - } - } - - // move new document into place. - _.each(_.keys(doc), function (k) { - // Note: this used to be for (var k in doc) however, this does not - // work right in Opera. Deleting from a doc while iterating over it - // would sometimes cause opera to skip some keys. - if (k !== '_id') - delete doc[k]; - }); - _.each(newDoc, function (v, k) { - doc[k] = v; - }); -}; - -// for a.b.c.2.d.e, keyparts should be ['a', 'b', 'c', '2', 'd', 'e'], -// and then you would operate on the 'e' property of the returned -// object. -// -// if options.noCreate is falsey, creates intermediate levels of -// structure as necessary, like mkdir -p (and raises an exception if -// that would mean giving a non-numeric property to an array.) if -// options.noCreate is true, return undefined instead. -// -// may modify the last element of keyparts to signal to the caller that it needs -// to use a different value to index into the returned object (for example, -// ['a', '01'] -> ['a', 1]). -// -// if forbidArray is true, return null if the keypath goes through an array. -// -// if options.arrayIndices is set, use its first element for the (first) '$' in -// the path. -var findModTarget = function (doc, keyparts, options) { - options = options || {}; - var usedArrayIndex = false; - for (var i = 0; i < keyparts.length; i++) { - var last = (i === keyparts.length - 1); - var keypart = keyparts[i]; - var indexable = isIndexable(doc); - if (!indexable) { - if (options.noCreate) - return undefined; - var e = MinimongoError( - "cannot use the part '" + keypart + "' to traverse " + doc); - e.setPropertyError = true; - throw e; - } - if (doc instanceof Array) { - if (options.forbidArray) - return null; - if (keypart === '$') { - if (usedArrayIndex) - throw MinimongoError("Too many positional (i.e. '$') elements"); - if (!options.arrayIndices || !options.arrayIndices.length) { - throw MinimongoError("The positional operator did not find the " + - "match needed from the query"); - } - keypart = options.arrayIndices[0]; - usedArrayIndex = true; - } else if (isNumericKey(keypart)) { - keypart = parseInt(keypart); - } else { - if (options.noCreate) - return undefined; - throw MinimongoError( - "can't append to array using string field name [" - + keypart + "]"); - } - if (last) - // handle 'a.01' - keyparts[i] = keypart; - if (options.noCreate && keypart >= doc.length) - return undefined; - while (doc.length < keypart) - doc.push(null); - if (!last) { - if (doc.length === keypart) - doc.push({}); - else if (typeof doc[keypart] !== "object") - throw MinimongoError("can't modify field '" + keyparts[i + 1] + - "' of list value " + JSON.stringify(doc[keypart])); - } - } else { - assertIsValidFieldName(keypart); - if (!(keypart in doc)) { - if (options.noCreate) - return undefined; - if (!last) - doc[keypart] = {}; - } - } - - if (last) - return doc; - doc = doc[keypart]; - } - - // notreached -}; - -var NO_CREATE_MODIFIERS = { - $unset: true, - $pop: true, - $rename: true, - $pull: true, - $pullAll: true -}; - -var MODIFIERS = { - $currentDate: function (target, field, arg) { - if (typeof arg === "object" && arg.hasOwnProperty("$type")) { - if (arg.$type !== "date") { - throw MinimongoError( - "Minimongo does currently only support the date type " + - "in $currentDate modifiers", - { field }); - } - } else if (arg !== true) { - throw MinimongoError("Invalid $currentDate modifier", { field }); - } - target[field] = new Date(); - }, - $min: function (target, field, arg) { - if (typeof arg !== "number") { - throw MinimongoError("Modifier $min allowed for numbers only", { field }); - } - if (field in target) { - if (typeof target[field] !== "number") { - throw MinimongoError( - "Cannot apply $min modifier to non-number", { field }); - } - if (target[field] > arg) { - target[field] = arg; - } - } else { - target[field] = arg; - } - }, - $max: function (target, field, arg) { - if (typeof arg !== "number") { - throw MinimongoError("Modifier $max allowed for numbers only", { field }); - } - if (field in target) { - if (typeof target[field] !== "number") { - throw MinimongoError( - "Cannot apply $max modifier to non-number", { field }); - } - if (target[field] < arg) { - target[field] = arg; - } - } else { - target[field] = arg; - } - }, - $inc: function (target, field, arg) { - if (typeof arg !== "number") - throw MinimongoError("Modifier $inc allowed for numbers only", { field }); - if (field in target) { - if (typeof target[field] !== "number") - throw MinimongoError( - "Cannot apply $inc modifier to non-number", { field }); - target[field] += arg; - } else { - target[field] = arg; - } - }, - $set: function (target, field, arg) { - if (!_.isObject(target)) { // not an array or an object - var e = MinimongoError( - "Cannot set property on non-object field", { field }); - e.setPropertyError = true; - throw e; - } - if (target === null) { - var e = MinimongoError("Cannot set property on null", { field }); - e.setPropertyError = true; - throw e; - } - assertHasValidFieldNames(arg); - target[field] = arg; - }, - $setOnInsert: function (target, field, arg) { - // converted to `$set` in `_modify` - }, - $unset: function (target, field, arg) { - if (target !== undefined) { - if (target instanceof Array) { - if (field in target) - target[field] = null; - } else - delete target[field]; - } - }, - $push: function (target, field, arg) { - if (target[field] === undefined) - target[field] = []; - if (!(target[field] instanceof Array)) - throw MinimongoError( - "Cannot apply $push modifier to non-array", { field }); - - if (!(arg && arg.$each)) { - // Simple mode: not $each - assertHasValidFieldNames(arg); - target[field].push(arg); - return; - } - - // Fancy mode: $each (and maybe $slice and $sort and $position) - var toPush = arg.$each; - if (!(toPush instanceof Array)) - throw MinimongoError("$each must be an array", { field }); - assertHasValidFieldNames(toPush); - - // Parse $position - var position = undefined; - if ('$position' in arg) { - if (typeof arg.$position !== "number") - throw MinimongoError("$position must be a numeric value", { field }); - // XXX should check to make sure integer - if (arg.$position < 0) - throw MinimongoError( - "$position in $push must be zero or positive", { field }); - position = arg.$position; - } - - // Parse $slice. - var slice = undefined; - if ('$slice' in arg) { - if (typeof arg.$slice !== "number") - throw MinimongoError("$slice must be a numeric value", { field }); - // XXX should check to make sure integer - slice = arg.$slice; - } - - // Parse $sort. - var sortFunction = undefined; - if (arg.$sort) { - if (slice === undefined) - throw MinimongoError("$sort requires $slice to be present", { field }); - // XXX this allows us to use a $sort whose value is an array, but that's - // actually an extension of the Node driver, so it won't work - // server-side. Could be confusing! - // XXX is it correct that we don't do geo-stuff here? - sortFunction = new Minimongo.Sorter(arg.$sort).getComparator(); - for (var i = 0; i < toPush.length; i++) { - if (LocalCollection._f._type(toPush[i]) !== 3) { - throw MinimongoError("$push like modifiers using $sort " + - "require all elements to be objects", { field }); - } - } - } - - // Actually push. - if (position === undefined) { - for (var j = 0; j < toPush.length; j++) - target[field].push(toPush[j]); - } else { - var spliceArguments = [position, 0]; - for (var j = 0; j < toPush.length; j++) - spliceArguments.push(toPush[j]); - Array.prototype.splice.apply(target[field], spliceArguments); - } - - // Actually sort. - if (sortFunction) - target[field].sort(sortFunction); - - // Actually slice. - if (slice !== undefined) { - if (slice === 0) - target[field] = []; // differs from Array.slice! - else if (slice < 0) - target[field] = target[field].slice(slice); - else - target[field] = target[field].slice(0, slice); - } - }, - $pushAll: function (target, field, arg) { - if (!(typeof arg === "object" && arg instanceof Array)) - throw MinimongoError("Modifier $pushAll/pullAll allowed for arrays only"); - assertHasValidFieldNames(arg); - var x = target[field]; - if (x === undefined) - target[field] = arg; - else if (!(x instanceof Array)) - throw MinimongoError( - "Cannot apply $pushAll modifier to non-array", { field }); - else { - for (var i = 0; i < arg.length; i++) - x.push(arg[i]); - } - }, - $addToSet: function (target, field, arg) { - var isEach = false; - if (typeof arg === "object") { - //check if first key is '$each' - const keys = Object.keys(arg); - if (keys[0] === "$each"){ - isEach = true; - } - } - var values = isEach ? arg["$each"] : [arg]; - assertHasValidFieldNames(values); - var x = target[field]; - if (x === undefined) - target[field] = values; - else if (!(x instanceof Array)) - throw MinimongoError( - "Cannot apply $addToSet modifier to non-array", { field }); - else { - _.each(values, function (value) { - for (var i = 0; i < x.length; i++) - if (LocalCollection._f._equal(value, x[i])) - return; - x.push(value); - }); - } - }, - $pop: function (target, field, arg) { - if (target === undefined) - return; - var x = target[field]; - if (x === undefined) - return; - else if (!(x instanceof Array)) - throw MinimongoError( - "Cannot apply $pop modifier to non-array", { field }); - else { - if (typeof arg === 'number' && arg < 0) - x.splice(0, 1); - else - x.pop(); - } - }, - $pull: function (target, field, arg) { - if (target === undefined) - return; - var x = target[field]; - if (x === undefined) - return; - else if (!(x instanceof Array)) - throw MinimongoError( - "Cannot apply $pull/pullAll modifier to non-array", { field }); - else { - var out = []; - if (arg != null && typeof arg === "object" && !(arg instanceof Array)) { - // XXX would be much nicer to compile this once, rather than - // for each document we modify.. but usually we're not - // modifying that many documents, so we'll let it slide for - // now - - // XXX Minimongo.Matcher isn't up for the job, because we need - // to permit stuff like {$pull: {a: {$gt: 4}}}.. something - // like {$gt: 4} is not normally a complete selector. - // same issue as $elemMatch possibly? - var matcher = new Minimongo.Matcher(arg); - for (var i = 0; i < x.length; i++) - if (!matcher.documentMatches(x[i]).result) - out.push(x[i]); - } else { - for (var i = 0; i < x.length; i++) - if (!LocalCollection._f._equal(x[i], arg)) - out.push(x[i]); - } - target[field] = out; - } - }, - $pullAll: function (target, field, arg) { - if (!(typeof arg === "object" && arg instanceof Array)) - throw MinimongoError( - "Modifier $pushAll/pullAll allowed for arrays only", { field }); - if (target === undefined) - return; - var x = target[field]; - if (x === undefined) - return; - else if (!(x instanceof Array)) - throw MinimongoError( - "Cannot apply $pull/pullAll modifier to non-array", { field }); - else { - var out = []; - for (var i = 0; i < x.length; i++) { - var exclude = false; - for (var j = 0; j < arg.length; j++) { - if (LocalCollection._f._equal(x[i], arg[j])) { - exclude = true; - break; - } - } - if (!exclude) - out.push(x[i]); - } - target[field] = out; - } - }, - $rename: function (target, field, arg, keypath, doc) { - if (keypath === arg) - // no idea why mongo has this restriction.. - throw MinimongoError("$rename source must differ from target", { field }); - if (target === null) - throw MinimongoError("$rename source field invalid", { field }); - if (typeof arg !== "string") - throw MinimongoError("$rename target must be a string", { field }); - if (arg.indexOf('\0') > -1) { - // Null bytes are not allowed in Mongo field names - // https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names - throw MinimongoError( - "The 'to' field for $rename cannot contain an embedded null byte", - { field }); - } - if (target === undefined) - return; - var v = target[field]; - delete target[field]; - - var keyparts = arg.split('.'); - var target2 = findModTarget(doc, keyparts, {forbidArray: true}); - if (target2 === null) - throw MinimongoError("$rename target field invalid", { field }); - var field2 = keyparts.pop(); - target2[field2] = v; - }, - $bit: function (target, field, arg) { - // XXX mongo only supports $bit on integers, and we only support - // native javascript numbers (doubles) so far, so we can't support $bit - throw MinimongoError("$bit is not supported", { field }); - } -}; diff --git a/packages/minimongo/objectid.js b/packages/minimongo/objectid.js deleted file mode 100644 index 3acf04e5023..00000000000 --- a/packages/minimongo/objectid.js +++ /dev/null @@ -1,57 +0,0 @@ -// Is this selector just shorthand for lookup by _id? -LocalCollection._selectorIsId = function (selector) { - return (typeof selector === "string") || - (typeof selector === "number") || - selector instanceof MongoID.ObjectID; -}; - -// Is the selector just lookup by _id (shorthand or not)? -LocalCollection._selectorIsIdPerhapsAsObject = function (selector) { - return LocalCollection._selectorIsId(selector) || - (selector && typeof selector === "object" && - selector._id && LocalCollection._selectorIsId(selector._id) && - _.size(selector) === 1); -}; - -// If this is a selector which explicitly constrains the match by ID to a finite -// number of documents, returns a list of their IDs. Otherwise returns -// null. Note that the selector may have other restrictions so it may not even -// match those document! We care about $in and $and since those are generated -// access-controlled update and remove. -LocalCollection._idsMatchedBySelector = function (selector) { - // Is the selector just an ID? - if (LocalCollection._selectorIsId(selector)) - return [selector]; - if (!selector) - return null; - - // Do we have an _id clause? - if (_.has(selector, '_id')) { - // Is the _id clause just an ID? - if (LocalCollection._selectorIsId(selector._id)) - return [selector._id]; - // Is the _id clause {_id: {$in: ["x", "y", "z"]}}? - if (selector._id && selector._id.$in - && _.isArray(selector._id.$in) - && !_.isEmpty(selector._id.$in) - && _.all(selector._id.$in, LocalCollection._selectorIsId)) { - return selector._id.$in; - } - return null; - } - - // If this is a top-level $and, and any of the clauses constrain their - // documents, then the whole selector is constrained by any one clause's - // constraint. (Well, by their intersection, but that seems unlikely.) - if (selector.$and && _.isArray(selector.$and)) { - for (var i = 0; i < selector.$and.length; ++i) { - var subIds = LocalCollection._idsMatchedBySelector(selector.$and[i]); - if (subIds) - return subIds; - } - } - - return null; -}; - - diff --git a/packages/minimongo/observe.js b/packages/minimongo/observe.js deleted file mode 100644 index bbc5f9eb425..00000000000 --- a/packages/minimongo/observe.js +++ /dev/null @@ -1,181 +0,0 @@ -// XXX maybe move these into another ObserveHelpers package or something - -// _CachingChangeObserver is an object which receives observeChanges callbacks -// and keeps a cache of the current cursor state up to date in self.docs. Users -// of this class should read the docs field but not modify it. You should pass -// the "applyChange" field as the callbacks to the underlying observeChanges -// call. Optionally, you can specify your own observeChanges callbacks which are -// invoked immediately before the docs field is updated; this object is made -// available as `this` to those callbacks. -LocalCollection._CachingChangeObserver = function (options) { - var self = this; - options = options || {}; - - var orderedFromCallbacks = options.callbacks && - LocalCollection._observeChangesCallbacksAreOrdered(options.callbacks); - if (_.has(options, 'ordered')) { - self.ordered = options.ordered; - if (options.callbacks && options.ordered !== orderedFromCallbacks) - throw Error("ordered option doesn't match callbacks"); - } else if (options.callbacks) { - self.ordered = orderedFromCallbacks; - } else { - throw Error("must provide ordered or callbacks"); - } - var callbacks = options.callbacks || {}; - - if (self.ordered) { - self.docs = new OrderedDict(MongoID.idStringify); - self.applyChange = { - addedBefore: function (id, fields, before) { - var doc = EJSON.clone(fields); - doc._id = id; - callbacks.addedBefore && callbacks.addedBefore.call( - self, id, fields, before); - // This line triggers if we provide added with movedBefore. - callbacks.added && callbacks.added.call(self, id, fields); - // XXX could `before` be a falsy ID? Technically - // idStringify seems to allow for them -- though - // OrderedDict won't call stringify on a falsy arg. - self.docs.putBefore(id, doc, before || null); - }, - movedBefore: function (id, before) { - var doc = self.docs.get(id); - callbacks.movedBefore && callbacks.movedBefore.call(self, id, before); - self.docs.moveBefore(id, before || null); - } - }; - } else { - self.docs = new LocalCollection._IdMap; - self.applyChange = { - added: function (id, fields) { - var doc = EJSON.clone(fields); - callbacks.added && callbacks.added.call(self, id, fields); - doc._id = id; - self.docs.set(id, doc); - } - }; - } - - // The methods in _IdMap and OrderedDict used by these callbacks are - // identical. - self.applyChange.changed = function (id, fields) { - var doc = self.docs.get(id); - if (!doc) - throw new Error("Unknown id for changed: " + id); - callbacks.changed && callbacks.changed.call( - self, id, EJSON.clone(fields)); - DiffSequence.applyChanges(doc, fields); - }; - self.applyChange.removed = function (id) { - callbacks.removed && callbacks.removed.call(self, id); - self.docs.remove(id); - }; -}; - -LocalCollection._observeFromObserveChanges = function (cursor, observeCallbacks) { - var transform = cursor.getTransform() || function (doc) {return doc;}; - var suppressed = !!observeCallbacks._suppress_initial; - - var observeChangesCallbacks; - if (LocalCollection._observeCallbacksAreOrdered(observeCallbacks)) { - // The "_no_indices" option sets all index arguments to -1 and skips the - // linear scans required to generate them. This lets observers that don't - // need absolute indices benefit from the other features of this API -- - // relative order, transforms, and applyChanges -- without the speed hit. - var indices = !observeCallbacks._no_indices; - observeChangesCallbacks = { - addedBefore: function (id, fields, before) { - var self = this; - if (suppressed || !(observeCallbacks.addedAt || observeCallbacks.added)) - return; - var doc = transform(_.extend(fields, {_id: id})); - if (observeCallbacks.addedAt) { - var index = indices - ? (before ? self.docs.indexOf(before) : self.docs.size()) : -1; - observeCallbacks.addedAt(doc, index, before); - } else { - observeCallbacks.added(doc); - } - }, - changed: function (id, fields) { - var self = this; - if (!(observeCallbacks.changedAt || observeCallbacks.changed)) - return; - var doc = EJSON.clone(self.docs.get(id)); - if (!doc) - throw new Error("Unknown id for changed: " + id); - var oldDoc = transform(EJSON.clone(doc)); - DiffSequence.applyChanges(doc, fields); - doc = transform(doc); - if (observeCallbacks.changedAt) { - var index = indices ? self.docs.indexOf(id) : -1; - observeCallbacks.changedAt(doc, oldDoc, index); - } else { - observeCallbacks.changed(doc, oldDoc); - } - }, - movedBefore: function (id, before) { - var self = this; - if (!observeCallbacks.movedTo) - return; - var from = indices ? self.docs.indexOf(id) : -1; - - var to = indices - ? (before ? self.docs.indexOf(before) : self.docs.size()) : -1; - // When not moving backwards, adjust for the fact that removing the - // document slides everything back one slot. - if (to > from) - --to; - observeCallbacks.movedTo(transform(EJSON.clone(self.docs.get(id))), - from, to, before || null); - }, - removed: function (id) { - var self = this; - if (!(observeCallbacks.removedAt || observeCallbacks.removed)) - return; - // technically maybe there should be an EJSON.clone here, but it's about - // to be removed from self.docs! - var doc = transform(self.docs.get(id)); - if (observeCallbacks.removedAt) { - var index = indices ? self.docs.indexOf(id) : -1; - observeCallbacks.removedAt(doc, index); - } else { - observeCallbacks.removed(doc); - } - } - }; - } else { - observeChangesCallbacks = { - added: function (id, fields) { - if (!suppressed && observeCallbacks.added) { - var doc = _.extend(fields, {_id: id}); - observeCallbacks.added(transform(doc)); - } - }, - changed: function (id, fields) { - var self = this; - if (observeCallbacks.changed) { - var oldDoc = self.docs.get(id); - var doc = EJSON.clone(oldDoc); - DiffSequence.applyChanges(doc, fields); - observeCallbacks.changed(transform(doc), - transform(EJSON.clone(oldDoc))); - } - }, - removed: function (id) { - var self = this; - if (observeCallbacks.removed) { - observeCallbacks.removed(transform(self.docs.get(id))); - } - } - }; - } - - var changeObserver = new LocalCollection._CachingChangeObserver( - {callbacks: observeChangesCallbacks}); - var handle = cursor.observeChanges(changeObserver.applyChange); - suppressed = false; - - return handle; -}; diff --git a/packages/minimongo/observe_handle.js b/packages/minimongo/observe_handle.js new file mode 100644 index 00000000000..3dc0fe37b5d --- /dev/null +++ b/packages/minimongo/observe_handle.js @@ -0,0 +1,2 @@ +// ObserveHandle: the return value of a live query. +export default class ObserveHandle {} diff --git a/packages/minimongo/package.js b/packages/minimongo/package.js index e576c8ecf08..d3befe47ddf 100644 --- a/packages/minimongo/package.js +++ b/packages/minimongo/package.js @@ -1,67 +1,48 @@ Package.describe({ - summary: "Meteor's client-side datastore: a port of MongoDB to Javascript", - version: '1.2.1' + summary: 'Meteor\'s client-side datastore: a port of MongoDB to Javascript', + version: '1.2.1', }); -Package.onUse(function (api) { +Package.onUse(api => { api.export('LocalCollection'); api.export('Minimongo'); + api.export('MinimongoTest', { testOnly: true }); api.export('MinimongoError', { testOnly: true }); + api.use([ - 'underscore', + // This package is used to get diff results on arrays and objects + 'diff-sequence', + 'ecmascript', 'ejson', + // This package is used for geo-location queries such as $near + 'geojson-utils', 'id-map', - 'ordered-dict', - 'tracker', 'mongo-id', + 'ordered-dict', 'random', - 'diff-sequence', - 'ecmascript' + 'tracker' ]); - // This package is used for geo-location queries such as $near - api.use('geojson-utils'); - // This package is used to get diff results on arrays and objects - api.use('diff-sequence'); - api.addFiles([ - 'minimongo.js', - 'wrap_transform.js', - 'helpers.js', - 'upsert_document.js', - 'selector.js', - 'sort.js', - 'projection.js', - 'modify.js', - 'diff.js', - 'id_map.js', - 'observe.js', - 'objectid.js' - ]); - - // Functionality used only by oplog tailing on the server side - api.addFiles([ - 'selector_projection.js', - 'selector_modifier.js', - 'sorter_projection.js' - ], 'server'); + api.mainModule('minimongo_client.js', 'client'); + api.mainModule('minimongo_server.js', 'server'); }); -Package.onTest(function (api) { - api.use('minimongo', ['client', 'server']); - api.use('test-helpers', 'client'); +Package.onTest(api => { + api.use('minimongo'); api.use([ - 'tinytest', - 'underscore', + 'ecmascript', 'ejson', + 'mongo-id', 'ordered-dict', 'random', - 'tracker', 'reactive-var', - 'mongo-id', - 'ecmascript' + 'test-helpers', + 'tinytest', + 'tracker' ]); - api.addFiles('minimongo_tests.js', 'client'); - api.addFiles('wrap_transform_tests.js'); - api.addFiles('minimongo_server_tests.js', 'server'); + + api.addFiles('minimongo_tests.js'); + api.addFiles('minimongo_tests_client.js', 'client'); + api.addFiles('minimongo_tests_server.js', 'server'); }); diff --git a/packages/minimongo/projection.js b/packages/minimongo/projection.js deleted file mode 100644 index 863f01f71d9..00000000000 --- a/packages/minimongo/projection.js +++ /dev/null @@ -1,174 +0,0 @@ -// Knows how to compile a fields projection to a predicate function. -// @returns - Function: a closure that filters out an object according to the -// fields projection rules: -// @param obj - Object: MongoDB-styled document -// @returns - Object: a document with the fields filtered out -// according to projection rules. Doesn't retain subfields -// of passed argument. -LocalCollection._compileProjection = function (fields) { - LocalCollection._checkSupportedProjection(fields); - - var _idProjection = _.isUndefined(fields._id) ? true : fields._id; - var details = projectionDetails(fields); - - // returns transformed doc according to ruleTree - var transform = function (doc, ruleTree) { - // Special case for "sets" - if (_.isArray(doc)) - return _.map(doc, function (subdoc) { return transform(subdoc, ruleTree); }); - - var res = details.including ? {} : EJSON.clone(doc); - _.each(ruleTree, function (rule, key) { - if (!_.has(doc, key)) - return; - if (_.isObject(rule)) { - // For sub-objects/subsets we branch - if (_.isObject(doc[key])) - res[key] = transform(doc[key], rule); - // Otherwise we don't even touch this subfield - } else if (details.including) - res[key] = EJSON.clone(doc[key]); - else - delete res[key]; - }); - - return res; - }; - - return function (obj) { - var res = transform(obj, details.tree); - - if (_idProjection && _.has(obj, '_id')) - res._id = obj._id; - if (!_idProjection && _.has(res, '_id')) - delete res._id; - return res; - }; -}; - -// 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 -projectionDetails = function (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. - var fieldsKeys = _.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 > 0 && - !(fieldsKeys.length === 1 && fieldsKeys[0] === '_id') && - !(_.contains(fieldsKeys, '_id') && fields['_id'])) - fieldsKeys = _.reject(fieldsKeys, function (key) { return key === '_id'; }); - - var including = null; // Unknown - - _.each(fieldsKeys, function (keyPath) { - var rule = !!fields[keyPath]; - if (including === null) - including = rule; - if (including !== rule) - // This error message is copied from MongoDB shell - throw MinimongoError("You cannot currently mix including and excluding fields."); - }); - - - var projectionRulesTree = pathsToTree( - fieldsKeys, - function (path) { return including; }, - function (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. - - var currentPath = fullPath; - var 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 { - tree: projectionRulesTree, - including: including - }; -}; - -// 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 -pathsToTree = function (paths, newLeafFn, conflictFn, tree) { - tree = tree || {}; - _.each(paths, function (keyPath) { - var treePos = tree; - var pathArr = keyPath.split('.'); - - // use _.all just for iteration with break - var success = _.all(pathArr.slice(0, -1), function (key, idx) { - if (!_.has(treePos, key)) - treePos[key] = {}; - else if (!_.isObject(treePos[key])) { - treePos[key] = conflictFn(treePos[key], - pathArr.slice(0, idx + 1).join('.'), - keyPath); - // break out of loop if we are failing for this path - if (!_.isObject(treePos[key])) - return false; - } - - treePos = treePos[key]; - return true; - }); - - if (success) { - var lastKey = _.last(pathArr); - if (!_.has(treePos, lastKey)) - treePos[lastKey] = newLeafFn(keyPath); - else - treePos[lastKey] = conflictFn(treePos[lastKey], keyPath, keyPath); - } - }); - - return tree; -}; - -LocalCollection._checkSupportedProjection = function (fields) { - if (!_.isObject(fields) || _.isArray(fields)) - throw MinimongoError("fields option must be an object"); - - _.each(fields, function (val, keyPath) { - if (_.contains(keyPath.split('.'), '$')) - throw MinimongoError("Minimongo doesn't support $ operator in projections yet."); - if (typeof val === 'object' && _.intersection(['$elemMatch', '$meta', '$slice'], _.keys(val)).length > 0) - throw MinimongoError("Minimongo doesn't support operators in projections yet."); - if (_.indexOf([1, 0, true, false], val) === -1) - throw MinimongoError("Projection values should be one of 1, 0, true, or false"); - }); -}; diff --git a/packages/minimongo/selector.js b/packages/minimongo/selector.js deleted file mode 100644 index 1bc1ab791c5..00000000000 --- a/packages/minimongo/selector.js +++ /dev/null @@ -1,1256 +0,0 @@ -// 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, isUpdate = false) { - 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); - // Set to true if selection is done for an update operation - // Default is false - // Used for $near array update (issue #3599) - self._isUpdate = isUpdate; -}; - -_.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); - // 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') { - 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) { - var simpleRange = _.contains(['$lt', '$lte', '$gt', '$gte'], operator) && - _.isNumber(operand); - var simpleEquality = _.contains(['$ne', '$eq'], operator) && !_.isObject(operand); - var simpleInclusion = _.contains(['$in', '$nin'], operator) && - _.isArray(operand) && !_.any(operand, _.isObject); - - if (! (simpleRange || simpleInclusion || simpleEquality)) { - 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 = { - $eq: function (operand) { - return convertElementMatcherToBranchedMatcher( - equalityElementMatcher(operand)); - }, - $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: 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. - - 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) - return null; - if(!value.type) - return GeoJSON.pointDistance(point, - { type: "Point", coordinates: pointToArray(value) }); - 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}; - _.every(branchedValues, function (branch) { - // if operation is an update, don't skip branches, just return the first one (#3599) - if (!matcher._isUpdate){ - if (!(typeof branch.value === "object")){ - return true; - } - var 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) - delete result.arrayIndices; - else - result.arrayIndices = branch.arrayIndices; - if (matcher._isUpdate) - return false; - return true; - }); - 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)); - }; - } - }; -}; - -// Helpers for $bitsAllSet/$bitsAnySet/$bitsAllClear/$bitsAnyClear. -var getOperandBitmask = function(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. - else if (EJSON.isBinary(operand)) { - return new Uint8Array(operand.buffer) - } - // position list - // If querying a list of bit positions, each must be a non-negative integer. Bit positions start at 0 from the least significant bit. - else if (isArray(operand) && operand.every(function (e) { - return Number.isInteger(e) && e >= 0 - })) { - var buffer = new ArrayBuffer((Math.max(...operand) >> 3) + 1) - var view = new Uint8Array(buffer) - operand.forEach(function (x) { - view[x >> 3] |= (1 << (x & 0x7)) - }) - return view - } - // bad operand - else { - 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)`) - } -} -var getValueBitmask = function (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. - var buffer = new ArrayBuffer(Math.max(length, 2 * Uint32Array.BYTES_PER_ELEMENT)); - var 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(function (byte, idx) { - view[idx] = 0xff - }) - } - return new Uint8Array(buffer) - } - // bindata - else if (EJSON.isBinary(value)) { - return new Uint8Array(value.buffer) - } - // no match - return false -} - -// 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; - }; - } - }, - $bitsAllSet: { - compileElementSelector: function (operand) { - var op = getOperandBitmask(operand, '$bitsAllSet') - return function (value) { - var bitmask = getValueBitmask(value, op.length) - return bitmask && op.every(function (byte, idx) { - return ((bitmask[idx] & byte) == byte) - }) - } - } - }, - $bitsAnySet: { - compileElementSelector: function (operand) { - var query = getOperandBitmask(operand, '$bitsAnySet') - return function (value) { - var bitmask = getValueBitmask(value, query.length) - return bitmask && query.some(function (byte, idx) { - return ((~bitmask[idx] & byte) !== byte) - }) - } - } - }, - $bitsAllClear: { - compileElementSelector: function (operand) { - var query = getOperandBitmask(operand, '$bitsAllClear') - return function (value) { - var bitmask = getValueBitmask(value, query.length) - return bitmask && query.every(function (byte, idx) { - return !(bitmask[idx] & byte) - }) - } - } - }, - $bitsAnyClear: { - compileElementSelector: function (operand) { - var query = getOperandBitmask(operand, '$bitsAnyClear') - return function (value) { - var bitmask = getValueBitmask(value, query.length) - return bitmask && query.some(function (byte, idx) { - return ((bitmask[idx] & byte) !== byte) - }) - } - } - }, - $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. - - // 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(_.omit(operand, _.keys(LOGICAL_OPERATORS)), 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 MongoID.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"); - } -}; diff --git a/packages/minimongo/selector_modifier.js b/packages/minimongo/selector_modifier.js deleted file mode 100644 index 54b37df68bf..00000000000 --- a/packages/minimongo/selector_modifier.js +++ /dev/null @@ -1,221 +0,0 @@ -// Returns true if the modifier applied to some document may change the result -// of matching the document by selector -// The modifier is always in a form of Object: -// - $set -// - 'a.b.22.z': value -// - 'foo.bar': 42 -// - $unset -// - 'abc.d': 1 -Minimongo.Matcher.prototype.affectedByModifier = function (modifier) { - var self = this; - // safe check for $set/$unset being objects - modifier = _.extend({ $set: {}, $unset: {} }, modifier); - var modifiedPaths = _.keys(modifier.$set).concat(_.keys(modifier.$unset)); - var meaningfulPaths = self._getPaths(); - - return _.any(modifiedPaths, function (path) { - var mod = path.split('.'); - return _.any(meaningfulPaths, function (meaningfulPath) { - var sel = meaningfulPath.split('.'); - var i = 0, j = 0; - - while (i < sel.length && j < mod.length) { - if (isNumericKey(sel[i]) && isNumericKey(mod[j])) { - // foo.4.bar selector affected by foo.4 modifier - // foo.3.bar selector unaffected by foo.4 modifier - if (sel[i] === mod[j]) - i++, j++; - else - return false; - } else if (isNumericKey(sel[i])) { - // foo.4.bar selector unaffected by foo.bar modifier - return false; - } else if (isNumericKey(mod[j])) { - j++; - } else if (sel[i] === mod[j]) - i++, j++; - else - return false; - } - - // One is a prefix of another, taking numeric fields into account - return true; - }); - }); -}; - -// Minimongo.Sorter gets a similar method, which delegates to a Matcher it made -// for this exact purpose. -Minimongo.Sorter.prototype.affectedByModifier = function (modifier) { - var self = this; - return self._selectorForAffectedByModifier.affectedByModifier(modifier); -}; - -// @param modifier - Object: MongoDB-styled modifier with `$set`s and `$unsets` -// only. (assumed to come from oplog) -// @returns - Boolean: if after applying the modifier, selector can start -// accepting the modified value. -// NOTE: assumes that document affected by modifier didn't match this Matcher -// before, so if modifier can't convince selector in a positive change it would -// stay 'false'. -// Currently doesn't support $-operators and numeric indices precisely. -Minimongo.Matcher.prototype.canBecomeTrueByModifier = function (modifier) { - var self = this; - if (!this.affectedByModifier(modifier)) - return false; - - modifier = _.extend({$set:{}, $unset:{}}, modifier); - var modifierPaths = _.keys(modifier.$set).concat(_.keys(modifier.$unset)); - - if (!self.isSimple()) - return true; - - if (_.any(self._getPaths(), pathHasNumericKeys) || - _.any(modifierPaths, pathHasNumericKeys)) - return true; - - // check if there is a $set or $unset that indicates something is an - // object rather than a scalar in the actual object where we saw $-operator - // NOTE: it is correct since we allow only scalars in $-operators - // Example: for selector {'a.b': {$gt: 5}} the modifier {'a.b.c':7} would - // definitely set the result to false as 'a.b' appears to be an object. - var expectedScalarIsObject = _.any(self._selector, function (sel, path) { - if (! isOperatorObject(sel)) - return false; - return _.any(modifierPaths, function (modifierPath) { - return startsWith(modifierPath, path + '.'); - }); - }); - - if (expectedScalarIsObject) - return false; - - // See if we can apply the modifier on the ideally matching object. If it - // still matches the selector, then the modifier could have turned the real - // object in the database into something matching. - var matchingDocument = EJSON.clone(self.matchingDocument()); - - // The selector is too complex, anything can happen. - if (matchingDocument === null) - return true; - - try { - LocalCollection._modify(matchingDocument, modifier); - } catch (e) { - // Couldn't set a property on a field which is a scalar or null in the - // selector. - // Example: - // real document: { 'a.b': 3 } - // selector: { 'a': 12 } - // converted selector (ideal document): { 'a': 12 } - // modifier: { $set: { 'a.b': 4 } } - // We don't know what real document was like but from the error raised by - // $set on a scalar field we can reason that the structure of real document - // is completely different. - if (e.name === "MinimongoError" && e.setPropertyError) - return false; - throw e; - } - - return self.documentMatches(matchingDocument).result; -}; - -// Returns an object that would match the selector if possible or null if the -// selector is too complex for us to analyze -// { 'a.b': { ans: 42 }, 'foo.bar': null, 'foo.baz': "something" } -// => { a: { b: { ans: 42 } }, foo: { bar: null, baz: "something" } } -Minimongo.Matcher.prototype.matchingDocument = function () { - var self = this; - - // check if it was computed before - if (self._matchingDocument !== undefined) - return self._matchingDocument; - - // If the analysis of this selector is too hard for our implementation - // fallback to "YES" - var fallback = false; - self._matchingDocument = pathsToTree(self._getPaths(), - function (path) { - var valueSelector = self._selector[path]; - if (isOperatorObject(valueSelector)) { - // if there is a strict equality, there is a good - // chance we can use one of those as "matching" - // dummy value - if (valueSelector.$eq) { - return valueSelector.$eq; - } else if (valueSelector.$in) { - var matcher = new Minimongo.Matcher({ placeholder: valueSelector }); - - // Return anything from $in that matches the whole selector for this - // path. If nothing matches, returns `undefined` as nothing can make - // this selector into `true`. - return _.find(valueSelector.$in, function (x) { - return matcher.documentMatches({ placeholder: x }).result; - }); - } else if (onlyContainsKeys(valueSelector, ['$gt', '$gte', '$lt', '$lte'])) { - var lowerBound = -Infinity, upperBound = Infinity; - _.each(['$lte', '$lt'], function (op) { - if (_.has(valueSelector, op) && valueSelector[op] < upperBound) - upperBound = valueSelector[op]; - }); - _.each(['$gte', '$gt'], function (op) { - if (_.has(valueSelector, op) && valueSelector[op] > lowerBound) - lowerBound = valueSelector[op]; - }); - - var middle = (lowerBound + upperBound) / 2; - var matcher = new Minimongo.Matcher({ placeholder: valueSelector }); - if (!matcher.documentMatches({ placeholder: middle }).result && - (middle === lowerBound || middle === upperBound)) - fallback = true; - - return middle; - } else if (onlyContainsKeys(valueSelector, ['$nin', '$ne'])) { - // Since self._isSimple makes sure $nin and $ne are not combined with - // objects or arrays, we can confidently return an empty object as it - // never matches any scalar. - return {}; - } else { - fallback = true; - } - } - return self._selector[path]; - }, - _.identity /*conflict resolution is no resolution*/); - - if (fallback) - self._matchingDocument = null; - - return self._matchingDocument; -}; - -var getPaths = function (sel) { - return _.keys(new Minimongo.Matcher(sel)._paths); - return _.chain(sel).map(function (v, k) { - // we don't know how to handle $where because it can be anything - if (k === "$where") - return ''; // matches everything - // we branch from $or/$and/$nor operator - if (_.contains(['$or', '$and', '$nor'], k)) - return _.map(v, getPaths); - // the value is a literal or some comparison operator - return k; - }).flatten().uniq().value(); -}; - -// A helper to ensure object has only certain keys -var onlyContainsKeys = function (obj, keys) { - return _.all(obj, function (v, k) { - return _.contains(keys, k); - }); -}; - -var pathHasNumericKeys = function (path) { - return _.any(path.split('.'), isNumericKey); -} - -// XXX from Underscore.String (http://epeli.github.com/underscore.string/) -var startsWith = function(str, starts) { - return str.length >= starts.length && - str.substring(0, starts.length) === starts; -}; diff --git a/packages/minimongo/selector_projection.js b/packages/minimongo/selector_projection.js deleted file mode 100644 index 5f6e101b5b2..00000000000 --- a/packages/minimongo/selector_projection.js +++ /dev/null @@ -1,69 +0,0 @@ -// Knows how to combine a mongo selector and a fields projection to a new fields -// projection taking into account active fields from the passed selector. -// @returns Object - projection object (same as fields option of mongo cursor) -Minimongo.Matcher.prototype.combineIntoProjection = function (projection) { - var self = this; - var selectorPaths = Minimongo._pathsElidingNumericKeys(self._getPaths()); - - // Special case for $where operator in the selector - projection should depend - // on all fields of the document. getSelectorPaths returns a list of paths - // selector depends on. If one of the paths is '' (empty string) representing - // the root or the whole document, complete projection should be returned. - if (_.contains(selectorPaths, '')) - return {}; - - return combineImportantPathsIntoProjection(selectorPaths, projection); -}; - -Minimongo._pathsElidingNumericKeys = function (paths) { - var self = this; - return _.map(paths, function (path) { - return _.reject(path.split('.'), isNumericKey).join('.'); - }); -}; - -combineImportantPathsIntoProjection = function (paths, projection) { - var prjDetails = projectionDetails(projection); - var tree = prjDetails.tree; - var mergedProjection = {}; - - // merge the paths to include - tree = pathsToTree(paths, - function (path) { return true; }, - function (node, path, fullPath) { return true; }, - tree); - mergedProjection = treeToPaths(tree); - if (prjDetails.including) { - // both selector and projection are pointing on fields to include - // so we can just return the merged tree - return mergedProjection; - } else { - // selector is pointing at fields to include - // projection is pointing at fields to exclude - // make sure we don't exclude important paths - var mergedExclProjection = {}; - _.each(mergedProjection, function (incl, path) { - if (!incl) - mergedExclProjection[path] = false; - }); - - return mergedExclProjection; - } -}; - -// Returns a set of key paths similar to -// { 'foo.bar': 1, 'a.b.c': 1 } -var treeToPaths = function (tree, prefix) { - prefix = prefix || ''; - var result = {}; - - _.each(tree, function (val, key) { - if (_.isObject(val)) - _.extend(result, treeToPaths(val, prefix + key + '.')); - else - result[prefix + key] = val; - }); - - return result; -}; - diff --git a/packages/minimongo/sort.js b/packages/minimongo/sorter.js similarity index 53% rename from packages/minimongo/sort.js rename to packages/minimongo/sorter.js index aebbcc2d4fe..a98f0ff9971 100644 --- a/packages/minimongo/sort.js +++ b/packages/minimongo/sorter.js @@ -1,3 +1,13 @@ +import { + ELEMENT_OPERATORS, + equalityElementMatcher, + expandArraysInBranches, + hasOwn, + isOperatorObject, + makeLookupFunction, + regexpElementMatcher, +} from './common.js'; + // Give a sort spec, which can be in any of these forms: // {"key1": 1, "key2": -1} // [["key1", "asc"], ["key2", "desc"]] @@ -11,186 +21,164 @@ // first object comes first in order, 1 if the second object comes // first, or 0 if neither object comes before the other. -Minimongo.Sorter = function (spec, options) { - var self = this; - options = options || {}; - - self._sortSpecParts = []; - self._sortFunction = null; - - var addSpecPart = function (path, ascending) { - if (!path) - throw Error("sort keys must be non-empty"); - if (path.charAt(0) === '$') - throw Error("unsupported sort key: " + path); - self._sortSpecParts.push({ - path: path, - lookup: makeLookupFunction(path, {forSort: true}), - ascending: ascending - }); - }; +export default class Sorter { + constructor(spec, options = {}) { + this._sortSpecParts = []; + this._sortFunction = null; + + const addSpecPart = (path, ascending) => { + if (!path) { + throw Error('sort keys must be non-empty'); + } - if (spec instanceof Array) { - for (var i = 0; i < spec.length; i++) { - if (typeof spec[i] === "string") { - addSpecPart(spec[i], true); - } else { - addSpecPart(spec[i][0], spec[i][1] !== "desc"); + if (path.charAt(0) === '$') { + throw Error(`unsupported sort key: ${path}`); } + + this._sortSpecParts.push({ + ascending, + lookup: makeLookupFunction(path, {forSort: true}), + path + }); + }; + + if (spec instanceof Array) { + spec.forEach(element => { + if (typeof element === 'string') { + addSpecPart(element, true); + } else { + addSpecPart(element[0], element[1] !== 'desc'); + } + }); + } else if (typeof spec === 'object') { + Object.keys(spec).forEach(key => { + addSpecPart(key, spec[key] >= 0); + }); + } else if (typeof spec === 'function') { + this._sortFunction = spec; + } else { + throw Error(`Bad sort specification: ${JSON.stringify(spec)}`); } - } else if (typeof spec === "object") { - _.each(spec, function (value, key) { - addSpecPart(key, value >= 0); - }); - } else if (typeof spec === "function") { - self._sortFunction = spec; - } else { - throw Error("Bad sort specification: " + JSON.stringify(spec)); - } - // If a function is specified for sorting, we skip the rest. - if (self._sortFunction) - return; - - // To implement affectedByModifier, we piggy-back on top of Matcher's - // affectedByModifier code; we create a selector that is affected by the same - // modifiers as this sort order. This is only implemented on the server. - if (self.affectedByModifier) { - var selector = {}; - _.each(self._sortSpecParts, function (spec) { - selector[spec.path] = 1; - }); - self._selectorForAffectedByModifier = new Minimongo.Matcher(selector); + // If a function is specified for sorting, we skip the rest. + if (this._sortFunction) { + return; + } + + // To implement affectedByModifier, we piggy-back on top of Matcher's + // affectedByModifier code; we create a selector that is affected by the + // same modifiers as this sort order. This is only implemented on the + // server. + if (this.affectedByModifier) { + const selector = {}; + + this._sortSpecParts.forEach(spec => { + selector[spec.path] = 1; + }); + + this._selectorForAffectedByModifier = new Minimongo.Matcher(selector); + } + + this._keyComparator = composeComparators( + this._sortSpecParts.map((spec, i) => this._keyFieldComparator(i)) + ); + + // If you specify a matcher for this Sorter, _keyFilter may be set to a + // function which selects whether or not a given "sort key" (tuple of values + // for the different sort spec fields) is compatible with the selector. + this._keyFilter = null; + + if (options.matcher) { + this._useWithMatcher(options.matcher); + } } - self._keyComparator = composeComparators( - _.map(self._sortSpecParts, function (spec, i) { - return self._keyFieldComparator(i); - })); - - // If you specify a matcher for this Sorter, _keyFilter may be set to a - // function which selects whether or not a given "sort key" (tuple of values - // for the different sort spec fields) is compatible with the selector. - self._keyFilter = null; - options.matcher && self._useWithMatcher(options.matcher); -}; - -// In addition to these methods, sorter_project.js defines combineIntoProjection -// on the server only. -_.extend(Minimongo.Sorter.prototype, { - getComparator: function (options) { - var self = this; - - // If sort is specified or have no distances, just use the comparator from + getComparator(options) { + // If sort is specified or have no distances, just use the comparator from // the source specification (which defaults to "everything is equal". // issue #3599 // https://docs.mongodb.com/manual/reference/operator/query/near/#sort-operation // sort effectively overrides $near - if (self._sortSpecParts.length || !options || !options.distances) { - return self._getBaseComparator(); + if (this._sortSpecParts.length || !options || !options.distances) { + return this._getBaseComparator(); } - var distances = options.distances; + const distances = options.distances; // Return a comparator which compares using $near distances. - return function (a, b) { - if (!distances.has(a._id)) - throw Error("Missing distance for " + a._id); - if (!distances.has(b._id)) - throw Error("Missing distance for " + b._id); - return distances.get(a._id) - distances.get(b._id); - }; - }, - - _getPaths: function () { - var self = this; - return _.pluck(self._sortSpecParts, 'path'); - }, - - // Finds the minimum key from the doc, according to the sort specs. (We say - // "minimum" here but this is with respect to the sort spec, so "descending" - // sort fields mean we're finding the max for that field.) - // - // Note that this is NOT "find the minimum value of the first field, the - // minimum value of the second field, etc"... it's "choose the - // lexicographically minimum value of the key vector, allowing only keys which - // you can find along the same paths". ie, for a doc {a: [{x: 0, y: 5}, {x: - // 1, y: 3}]} with sort spec {'a.x': 1, 'a.y': 1}, the only keys are [0,5] and - // [1,3], and the minimum key is [0,5]; notably, [0,3] is NOT a key. - _getMinKeyFromDoc: function (doc) { - var self = this; - var minKey = null; - - self._generateKeysFromDoc(doc, function (key) { - if (!self._keyCompatibleWithSelector(key)) - return; - - if (minKey === null) { - minKey = key; - return; + return (a, b) => { + if (!distances.has(a._id)) { + throw Error(`Missing distance for ${a._id}`); } - if (self._compareKeys(key, minKey) < 0) { - minKey = key; + + if (!distances.has(b._id)) { + throw Error(`Missing distance for ${b._id}`); } - }); - // This could happen if our key filter somehow filters out all the keys even - // though somehow the selector matches. - if (minKey === null) - throw Error("sort selector found no keys in doc?"); - return minKey; - }, + return distances.get(a._id) - distances.get(b._id); + }; + } + + // Takes in two keys: arrays whose lengths match the number of spec + // parts. Returns negative, 0, or positive based on using the sort spec to + // compare fields. + _compareKeys(key1, key2) { + if (key1.length !== this._sortSpecParts.length || + key2.length !== this._sortSpecParts.length) { + throw Error('Key has wrong length'); + } - _keyCompatibleWithSelector: function (key) { - var self = this; - return !self._keyFilter || self._keyFilter(key); - }, + return this._keyComparator(key1, key2); + } // Iterates over each possible "key" from doc (ie, over each branch), calling // 'cb' with the key. - _generateKeysFromDoc: function (doc, cb) { - var self = this; - - if (self._sortSpecParts.length === 0) - throw new Error("can't generate keys without a spec"); - - // maps index -> ({'' -> value} or {path -> value}) - var valuesByIndexAndPath = []; + _generateKeysFromDoc(doc, cb) { + if (this._sortSpecParts.length === 0) { + throw new Error('can\'t generate keys without a spec'); + } - var pathFromIndices = function (indices) { - return indices.join(',') + ','; - }; + const pathFromIndices = indices => `${indices.join(',')},`; - var knownPaths = null; + let knownPaths = null; - _.each(self._sortSpecParts, function (spec, whichField) { + // maps index -> ({'' -> value} or {path -> value}) + const valuesByIndexAndPath = this._sortSpecParts.map(spec => { // Expand any leaf arrays that we find, and ignore those arrays // themselves. (We never sort based on an array itself.) - var branches = expandArraysInBranches(spec.lookup(doc), true); + let branches = expandArraysInBranches(spec.lookup(doc), true); // If there are no values for a key (eg, key goes to an empty array), // pretend we found one null value. - if (!branches.length) + if (!branches.length) { branches = [{value: null}]; + } - var usedPaths = false; - valuesByIndexAndPath[whichField] = {}; - _.each(branches, function (branch) { + const element = Object.create(null); + let usedPaths = false; + + branches.forEach(branch => { if (!branch.arrayIndices) { // If there are no array indices for a branch, then it must be the // only branch, because the only thing that produces multiple branches // is the use of arrays. - if (branches.length > 1) - throw Error("multiple branches but no array used?"); - valuesByIndexAndPath[whichField][''] = branch.value; + if (branches.length > 1) { + throw Error('multiple branches but no array used?'); + } + + element[''] = branch.value; return; } usedPaths = true; - var path = pathFromIndices(branch.arrayIndices); - if (_.has(valuesByIndexAndPath[whichField], path)) - throw Error("duplicate path: " + path); - valuesByIndexAndPath[whichField][path] = branch.value; + + const path = pathFromIndices(branch.arrayIndices); + + if (hasOwn.call(element, path)) { + throw Error(`duplicate path: ${path}`); + } + + element[path] = branch.value; // If two sort fields both go into arrays, they have to go into the // exact same arrays and we have to find the same paths. This is @@ -202,97 +190,136 @@ _.extend(Minimongo.Sorter.prototype, { // and 'a.x.y' are both arrays, but we don't allow this for now. // #NestedArraySort // XXX achieve full compatibility here - if (knownPaths && !_.has(knownPaths, path)) { - throw Error("cannot index parallel arrays"); + if (knownPaths && !hasOwn.call(knownPaths, path)) { + throw Error('cannot index parallel arrays'); } }); if (knownPaths) { // Similarly to above, paths must match everywhere, unless this is a // non-array field. - if (!_.has(valuesByIndexAndPath[whichField], '') && - _.size(knownPaths) !== _.size(valuesByIndexAndPath[whichField])) { - throw Error("cannot index parallel arrays!"); + if (!hasOwn.call(element, '') && + Object.keys(knownPaths).length !== Object.keys(element).length) { + throw Error('cannot index parallel arrays!'); } } else if (usedPaths) { knownPaths = {}; - _.each(valuesByIndexAndPath[whichField], function (x, path) { + + Object.keys(element).forEach(path => { knownPaths[path] = true; }); } + + return element; }); if (!knownPaths) { // Easy case: no use of arrays. - var soleKey = _.map(valuesByIndexAndPath, function (values) { - if (!_.has(values, '')) - throw Error("no value in sole key case?"); + const soleKey = valuesByIndexAndPath.map(values => { + if (!hasOwn.call(values, '')) { + throw Error('no value in sole key case?'); + } + return values['']; }); + cb(soleKey); + return; } - _.each(knownPaths, function (x, path) { - var key = _.map(valuesByIndexAndPath, function (values) { - if (_.has(values, '')) + Object.keys(knownPaths).forEach(path => { + const key = valuesByIndexAndPath.map(values => { + if (hasOwn.call(values, '')) { return values['']; - if (!_.has(values, path)) - throw Error("missing path?"); + } + + if (!hasOwn.call(values, path)) { + throw Error('missing path?'); + } + return values[path]; }); + cb(key); }); - }, + } - // Takes in two keys: arrays whose lengths match the number of spec - // parts. Returns negative, 0, or positive based on using the sort spec to - // compare fields. - _compareKeys: function (key1, key2) { - var self = this; - if (key1.length !== self._sortSpecParts.length || - key2.length !== self._sortSpecParts.length) { - throw Error("Key has wrong length"); + // Returns a comparator that represents the sort specification (but not + // including a possible geoquery distance tie-breaker). + _getBaseComparator() { + if (this._sortFunction) { + return this._sortFunction; } - return self._keyComparator(key1, key2); - }, + // If we're only sorting on geoquery distance and no specs, just say + // everything is equal. + if (!this._sortSpecParts.length) { + return (doc1, doc2) => 0; + } - // Given an index 'i', returns a comparator that compares two key arrays based - // on field 'i'. - _keyFieldComparator: function (i) { - var self = this; - var invert = !self._sortSpecParts[i].ascending; - return function (key1, key2) { - var compare = LocalCollection._f._cmp(key1[i], key2[i]); - if (invert) - compare = -compare; - return compare; + return (doc1, doc2) => { + const key1 = this._getMinKeyFromDoc(doc1); + const key2 = this._getMinKeyFromDoc(doc2); + return this._compareKeys(key1, key2); }; - }, + } - // Returns a comparator that represents the sort specification (but not - // including a possible geoquery distance tie-breaker). - _getBaseComparator: function () { - var self = this; + // Finds the minimum key from the doc, according to the sort specs. (We say + // "minimum" here but this is with respect to the sort spec, so "descending" + // sort fields mean we're finding the max for that field.) + // + // Note that this is NOT "find the minimum value of the first field, the + // minimum value of the second field, etc"... it's "choose the + // lexicographically minimum value of the key vector, allowing only keys which + // you can find along the same paths". ie, for a doc {a: [{x: 0, y: 5}, {x: + // 1, y: 3}]} with sort spec {'a.x': 1, 'a.y': 1}, the only keys are [0,5] and + // [1,3], and the minimum key is [0,5]; notably, [0,3] is NOT a key. + _getMinKeyFromDoc(doc) { + let minKey = null; - if (self._sortFunction) - return self._sortFunction; + this._generateKeysFromDoc(doc, key => { + if (!this._keyCompatibleWithSelector(key)) { + return; + } - // If we're only sorting on geoquery distance and no specs, just say - // everything is equal. - if (!self._sortSpecParts.length) { - return function (doc1, doc2) { - return 0; - }; + if (minKey === null) { + minKey = key; + return; + } + + if (this._compareKeys(key, minKey) < 0) { + minKey = key; + } + }); + + // This could happen if our key filter somehow filters out all the keys even + // though somehow the selector matches. + if (minKey === null) { + throw Error('sort selector found no keys in doc?'); } - return function (doc1, doc2) { - var key1 = self._getMinKeyFromDoc(doc1); - var key2 = self._getMinKeyFromDoc(doc2); - return self._compareKeys(key1, key2); + return minKey; + } + + _getPaths() { + return this._sortSpecParts.map(part => part.path); + } + + _keyCompatibleWithSelector(key) { + return !this._keyFilter || this._keyFilter(key); + } + + // Given an index 'i', returns a comparator that compares two key arrays based + // on field 'i'. + _keyFieldComparator(i) { + const invert = !this._sortSpecParts[i].ascending; + + return (key1, key2) => { + const compare = LocalCollection._f._cmp(key1[i], key2[i]); + return invert ? -compare : compare; }; - }, + } // In MongoDB, if you have documents // {_id: 'x', a: [1, 10]} and @@ -313,36 +340,40 @@ _.extend(Minimongo.Sorter.prototype, { // skip sort keys that don't match the selector. The logic here is pretty // subtle and undocumented; we've gotten as close as we can figure out based // on our understanding of Mongo's behavior. - _useWithMatcher: function (matcher) { - var self = this; - - if (self._keyFilter) - throw Error("called _useWithMatcher twice?"); + _useWithMatcher(matcher) { + if (this._keyFilter) { + throw Error('called _useWithMatcher twice?'); + } // If we are only sorting by distance, then we're not going to bother to // build a key filter. // XXX figure out how geoqueries interact with this stuff - if (_.isEmpty(self._sortSpecParts)) + if (!this._sortSpecParts.length) { return; + } - var selector = matcher._selector; + const selector = matcher._selector; // If the user just passed a literal function to find(), then we can't get a // key filter from it. - if (selector instanceof Function) + if (selector instanceof Function) { return; + } - var constraintsByPath = {}; - _.each(self._sortSpecParts, function (spec, i) { + const constraintsByPath = {}; + + this._sortSpecParts.forEach(spec => { constraintsByPath[spec.path] = []; }); - _.each(selector, function (subSelector, key) { - // XXX support $and and $or + Object.keys(selector).forEach(key => { + const subSelector = selector[key]; - var constraints = constraintsByPath[key]; - if (!constraints) + // XXX support $and and $or + const constraints = constraintsByPath[key]; + if (!constraints) { return; + } // XXX it looks like the real MongoDB implementation isn't "does the // regexp match" but "does the value fall into a range named by the @@ -355,30 +386,39 @@ _.extend(Minimongo.Sorter.prototype, { // index to use, which means it only cares about regexps that match // one range (with a literal prefix), and both 'i' and 'm' prevent the // literal prefix of the regexp from actually meaning one range. - if (subSelector.ignoreCase || subSelector.multiline) + if (subSelector.ignoreCase || subSelector.multiline) { return; + } + constraints.push(regexpElementMatcher(subSelector)); return; } if (isOperatorObject(subSelector)) { - _.each(subSelector, function (operand, operator) { - if (_.contains(['$lt', '$lte', '$gt', '$gte'], operator)) { + Object.keys(subSelector).forEach(operator => { + const operand = subSelector[operator]; + + if (['$lt', '$lte', '$gt', '$gte'].includes(operator)) { // XXX this depends on us knowing that these operators don't use any // of the arguments to compileElementSelector other than operand. constraints.push( - ELEMENT_OPERATORS[operator].compileElementSelector(operand)); + ELEMENT_OPERATORS[operator].compileElementSelector(operand) + ); } // See comments in the RegExp block above. if (operator === '$regex' && !subSelector.$options) { constraints.push( ELEMENT_OPERATORS.$regex.compileElementSelector( - operand, subSelector)); + operand, + subSelector + ) + ); } // XXX support {$exists: true}, $mod, $type, $in, $elemMatch }); + return; } @@ -390,30 +430,31 @@ _.extend(Minimongo.Sorter.prototype, { // others; we shouldn't create a key filter unless the first sort field is // restricted, though after that point we can restrict the other sort fields // or not as we wish. - if (_.isEmpty(constraintsByPath[self._sortSpecParts[0].path])) + if (!constraintsByPath[this._sortSpecParts[0].path].length) { return; + } - self._keyFilter = function (key) { - return _.all(self._sortSpecParts, function (specPart, index) { - return _.all(constraintsByPath[specPart.path], function (f) { - return f(key[index]); - }); - }); - }; + this._keyFilter = key => + this._sortSpecParts.every((specPart, index) => + constraintsByPath[specPart.path].every(fn => fn(key[index])) + ) + ; } -}); +} // Given an array of comparators // (functions (a,b)->(negative or positive or zero)), returns a single // comparator which uses each comparator in order and returns the first // non-zero value. -var composeComparators = function (comparatorArray) { - return function (a, b) { - for (var i = 0; i < comparatorArray.length; ++i) { - var compare = comparatorArray[i](a, b); - if (compare !== 0) +function composeComparators(comparatorArray) { + return (a, b) => { + for (let i = 0; i < comparatorArray.length; ++i) { + const compare = comparatorArray[i](a, b); + if (compare !== 0) { return compare; + } } + return 0; }; -}; +} diff --git a/packages/minimongo/sorter_projection.js b/packages/minimongo/sorter_projection.js deleted file mode 100644 index dfa18af1b52..00000000000 --- a/packages/minimongo/sorter_projection.js +++ /dev/null @@ -1,5 +0,0 @@ -Minimongo.Sorter.prototype.combineIntoProjection = function (projection) { - var self = this; - var specPaths = Minimongo._pathsElidingNumericKeys(self._getPaths()); - return combineImportantPathsIntoProjection(specPaths, projection); -}; diff --git a/packages/minimongo/upsert_document.js b/packages/minimongo/upsert_document.js deleted file mode 100644 index e5d9d864b79..00000000000 --- a/packages/minimongo/upsert_document.js +++ /dev/null @@ -1,124 +0,0 @@ -// 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 - -// Fills a document with certain fields from an upsert selector -export default function populateDocumentWithQueryFields (query, document = {}) { - if (Object.getPrototypeOf(query) === Object.prototype) { - // handle implicit $and - Object.keys(query).forEach(function (key) { - const value = query[key]; - if (key === '$and') { - // handle explicit $and - value.forEach(sq => populateDocumentWithQueryFields(sq, 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; -} - -// 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(k => k[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(function (k) { - const v = value[k]; - if (k === '$eq') { - populateDocumentWithKeyValue(document, key, v); - } else if (k === '$all') { - // every value for $all should be dealt with as separate $eq-s - v.forEach(vx => populateDocumentWithKeyValue(document, key, vx)); - } - }); - } -} - -// 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; -} - -// Recursively validates an object that is nested more than one level deep -function validateObject (obj, path) { - if (obj && Object.getPrototypeOf(obj) === Object.prototype) { - Object.keys(obj).forEach(function (key) { - validateKeyInPath(key, path); - validateObject(obj[key], path + '.' + key); - }); - } -} - -// 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.'); - } -} diff --git a/packages/minimongo/validation.js b/packages/minimongo/validation.js deleted file mode 100644 index ef73560a722..00000000000 --- a/packages/minimongo/validation.js +++ /dev/null @@ -1,24 +0,0 @@ -// Make sure field names do not contain Mongo restricted -// characters ('.', '$', '\0'). -// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names -const invalidCharMsg = { - '.': "contain '.'", - '$': "start with '$'", - '\0': "contain null bytes", -}; -export function assertIsValidFieldName(key) { - let match; - if (_.isString(key) && (match = key.match(/^\$|\.|\0/))) { - throw MinimongoError(`Key ${key} must not ${invalidCharMsg[match[0]]}`); - } -}; - -// checks if all field names in an object are valid -export function assertHasValidFieldNames(doc){ - if (doc && typeof doc === "object") { - JSON.stringify(doc, (key, value) => { - assertIsValidFieldName(key); - return value; - }); - } -}; \ No newline at end of file diff --git a/packages/minimongo/wrap_transform.js b/packages/minimongo/wrap_transform.js deleted file mode 100644 index 561931e3786..00000000000 --- a/packages/minimongo/wrap_transform.js +++ /dev/null @@ -1,46 +0,0 @@ -// Wrap a transform function to return objects that have the _id field -// of the untransformed document. This ensures that subsystems such as -// the observe-sequence package that call `observe` can keep track of -// the documents identities. -// -// - Require that it returns objects -// - If the return value has an _id field, verify that it matches the -// original _id field -// - If the return value doesn't have an _id field, add it back. -LocalCollection.wrapTransform = function (transform) { - if (! transform) - return null; - - // No need to doubly-wrap transforms. - if (transform.__wrappedTransform__) - return transform; - - var wrapped = function (doc) { - if (!_.has(doc, '_id')) { - // XXX do we ever have a transform on the oplog's collection? because that - // collection has no _id. - throw new Error("can only transform documents with _id"); - } - - var id = doc._id; - // XXX consider making tracker a weak dependency and checking Package.tracker here - var transformed = Tracker.nonreactive(function () { - return transform(doc); - }); - - if (!isPlainObject(transformed)) { - throw new Error("transform must return object"); - } - - if (_.has(transformed, '_id')) { - if (!EJSON.equals(transformed._id, id)) { - throw new Error("transformed document can't have different _id"); - } - } else { - transformed._id = id; - } - return transformed; - }; - wrapped.__wrappedTransform__ = true; - return wrapped; -}; diff --git a/packages/minimongo/wrap_transform_tests.js b/packages/minimongo/wrap_transform_tests.js deleted file mode 100644 index 6385a29120c..00000000000 --- a/packages/minimongo/wrap_transform_tests.js +++ /dev/null @@ -1,58 +0,0 @@ -Tinytest.add("minimongo - wrapTransform", function (test) { - var wrap = LocalCollection.wrapTransform; - - // Transforming no function gives falsey. - test.isFalse(wrap(undefined)); - test.isFalse(wrap(null)); - - // It's OK if you don't change the ID. - var validTransform = function (doc) { - delete doc.x; - doc.y = 42; - doc.z = function () { return 43; }; - return doc; - }; - var transformed = wrap(validTransform)({_id: "asdf", x: 54}); - test.equal(_.keys(transformed), ['_id', 'y', 'z']); - test.equal(transformed.y, 42); - test.equal(transformed.z(), 43); - - // Ensure that ObjectIDs work (even if the _ids in question are not ===-equal) - var oid1 = new MongoID.ObjectID(); - var oid2 = new MongoID.ObjectID(oid1.toHexString()); - test.equal(wrap(function () {return {_id: oid2};})({_id: oid1}), - {_id: oid2}); - - // transform functions must return objects - var invalidObjects = [ - "asdf", new MongoID.ObjectID(), false, null, true, - 27, [123], /adsf/, new Date, function () {}, undefined - ]; - _.each(invalidObjects, function (invalidObject) { - var wrapped = wrap(function () { return invalidObject; }); - test.throws(function () { - wrapped({_id: "asdf"}); - }); - }, /transform must return object/); - - // transform functions may not change _ids - var wrapped = wrap(function (doc) { doc._id = 'x'; return doc; }); - test.throws(function () { - wrapped({_id: 'y'}); - }, /can't have different _id/); - - // transform functions may remove _ids - test.equal({_id: 'a', x: 2}, - wrap(function (d) {delete d._id; return d;})({_id: 'a', x: 2})); - - // test that wrapped transform functions are nonreactive - var unwrapped = function (doc) { - test.isFalse(Tracker.active); - return doc; - }; - var handle = Tracker.autorun(function () { - test.isTrue(Tracker.active); - wrap(unwrapped)({_id: "xxx"}); - }); - handle.stop(); -});