/
githubmirror
/
meteor
Обзор
Документация
Войти
/
githubmirror
/
meteor
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
devel
packages/minimongo/common.js
1 413 строк
49 KB
italo jose
Reapply "Merge pull request #14188 from mvogttech/mongo-collation-support"
01 май 2026, 15:49
01 май 2026, 15:49
8025ba4
Код
Авторство
О чём код?
import LocalCollection from './local_collection.js'; export const hasOwn = Object.prototype.hasOwnProperty; export class MiniMongoQueryError extends Error {} // 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 new MiniMongoQueryError('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, valueSelector, matcher) { if (!Array.isArray(operand)) { throw new MiniMongoQueryError('$in needs an array'); } const collator = matcher && matcher._collator; const elementMatchers = operand.map(option => { if (option instanceof RegExp) { return regexpElementMatcher(option); } if (isOperatorObject(option)) { throw new MiniMongoQueryError('cannot nest $ under $in'); } return equalityElementMatcher(option, collator); }); 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 new MiniMongoQueryError('$size needs a number'); } return value => Array.isArray(value) && value.length === operand; }, }, $type: { // {a: [5]} must not match {a: {$type: 4}} (4 means array), but it should // match {a: {$type: 1}} (1 means number), and {a: [[5]]} must match {$a: // {$type: 4}}. Thus, when we see a leaf array, we *should* expand it but // should *not* include it itself. dontIncludeLeafArrays: true, compileElementSelector(operand) { if (typeof operand === 'string') { const operandAliasMap = { 'double': 1, 'string': 2, 'object': 3, 'array': 4, 'binData': 5, 'undefined': 6, 'objectId': 7, 'bool': 8, 'date': 9, 'null': 10, 'regex': 11, 'dbPointer': 12, 'javascript': 13, 'symbol': 14, 'javascriptWithScope': 15, 'int': 16, 'timestamp': 17, 'long': 18, 'decimal': 19, 'minKey': -1, 'maxKey': 127, }; if (!hasOwn.call(operandAliasMap, operand)) { throw new MiniMongoQueryError(`unknown string alias for $type: ${operand}`); } operand = operandAliasMap[operand]; } else if (typeof operand === 'number') { if (operand === 0 || operand < -1 || (operand > 19 && operand !== 127)) { throw new MiniMongoQueryError(`Invalid numerical $type code: ${operand}`); } } else { throw new MiniMongoQueryError('argument to $type is not a number or a string'); } return value => ( value !== undefined && LocalCollection._f._type(value) === operand ); }, }, $bitsAllSet: { compileElementSelector(operand) { const mask = getOperandBitmask(operand, '$bitsAllSet'); return value => { const bitmask = getValueBitmask(value, mask.length); return bitmask && mask.every((byte, i) => (bitmask[i] & byte) === byte); }; }, }, $bitsAnySet: { compileElementSelector(operand) { const mask = getOperandBitmask(operand, '$bitsAnySet'); return value => { const bitmask = getValueBitmask(value, mask.length); return bitmask && mask.some((byte, i) => (~bitmask[i] & byte) !== byte); }; }, }, $bitsAllClear: { compileElementSelector(operand) { const mask = getOperandBitmask(operand, '$bitsAllClear'); return value => { const bitmask = getValueBitmask(value, mask.length); return bitmask && mask.every((byte, i) => !(bitmask[i] & byte)); }; }, }, $bitsAnyClear: { compileElementSelector(operand) { const mask = getOperandBitmask(operand, '$bitsAnyClear'); return value => { const bitmask = getValueBitmask(value, mask.length); return bitmask && mask.some((byte, i) => (bitmask[i] & byte) !== byte); }; }, }, $regex: { compileElementSelector(operand, valueSelector) { if (!(typeof operand === 'string' || operand instanceof RegExp)) { throw new MiniMongoQueryError('$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 MiniMongoQueryError('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 new MiniMongoQueryError('$elemMatch need an object'); } const isDocMatcher = !isOperatorObject( Object.keys(operand) .filter(key => !hasOwn.call(LOGICAL_OPERATORS, key)) .reduce((a, b) => Object.assign(a, {[b]: operand[b]}), {}), true); let subMatcher; if (isDocMatcher) { // This is NOT the same as compileValueSelector(operand), and not just // because of the slightly different calling convention. // {$elemMatch: {x: 3}} means "an element has a field x:3", not // "consists only of a field x:3". Also, regexps and sub-$ are allowed. subMatcher = compileDocumentSelector(operand, matcher, {inElemMatch: true}); } else { subMatcher = compileValueSelector(operand, matcher); } return value => { if (!Array.isArray(value)) { return false; } for (let i = 0; i < value.length; ++i) { const arrayElement = value[i]; let arg; if (isDocMatcher) { // We can only match {$elemMatch: {b: 3}} against objects. // (We can also match against arrays, if there's numeric indices, // eg {$elemMatch: {'0.b': 3}} or {$elemMatch: {0: 3}}.) if (!isIndexable(arrayElement)) { return false; } arg = arrayElement; } else { // dontIterate ensures that {a: {$elemMatch: {$gt: 5}}} matches // {a: [8]} but not {a: [[8]]} arg = [{value: arrayElement, dontIterate: true}]; } // XXX support $near in $elemMatch by propagating $distance? if (subMatcher(arg).result) { return i; // specially understood to mean "use as arrayIndices" } } return false; }; }, }, }; // Operators that appear at the top level of a document selector. const LOGICAL_OPERATORS = { $and(subSelector, matcher, inElemMatch) { return andDocumentMatchers( compileArrayOfDocumentSelectors(subSelector, matcher, inElemMatch) ); }, $or(subSelector, matcher, inElemMatch) { const matchers = compileArrayOfDocumentSelectors( subSelector, matcher, inElemMatch ); // Special case: if there is only one matcher, use it directly, *preserving* // any arrayIndices it returns. if (matchers.length === 1) { return matchers[0]; } return doc => { const result = matchers.some(fn => fn(doc).result); // $or does NOT set arrayIndices when it has multiple // sub-expressions. (Tested against MongoDB.) return {result}; }; }, $nor(subSelector, matcher, inElemMatch) { const matchers = compileArrayOfDocumentSelectors( subSelector, matcher, inElemMatch ); return doc => { const result = matchers.every(fn => !fn(doc).result); // Never set arrayIndices, because we only match if nothing in particular // 'matched' (and because this is consistent with MongoDB). return {result}; }; }, $where(selectorValue, matcher) { // Record that *any* path may be used. matcher._recordPathUsed(''); matcher._hasWhere = true; if (!(selectorValue instanceof Function)) { // XXX MongoDB seems to have more complex logic to decide where or or not // to add 'return'; not sure exactly what it is. selectorValue = Function('obj', `return ${selectorValue}`); } // We make the document available as both `this` and `obj`. // // XXX not sure what we should do if this throws return doc => ({result: selectorValue.call(doc, doc)}); }, // This is just used as a comment in the query (in MongoDB, it also ends up in // query logs); it has no effect on the actual selection. $comment() { return () => ({result: true}); }, }; // Operators that (unlike LOGICAL_OPERATORS) pertain to individual paths in a // document, but (unlike ELEMENT_OPERATORS) do not have a simple definition as // "match each branched value independently and combine with // convertElementMatcherToBranchedMatcher". const VALUE_OPERATORS = { $eq(operand, valueSelector, matcher) { return convertElementMatcherToBranchedMatcher( equalityElementMatcher(operand, matcher && matcher._collator) ); }, $not(operand, valueSelector, matcher) { return invertBranchedMatcher(compileValueSelector(operand, matcher)); }, $ne(operand, valueSelector, matcher) { return invertBranchedMatcher( convertElementMatcherToBranchedMatcher( equalityElementMatcher(operand, matcher && matcher._collator) ) ); }, $nin(operand, valueSelector, matcher) { return invertBranchedMatcher( convertElementMatcherToBranchedMatcher( ELEMENT_OPERATORS.$in.compileElementSelector(operand, valueSelector, matcher) ) ); }, $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 new MiniMongoQueryError('$options needs a $regex'); } return everythingMatcher; }, // $maxDistance is basically an argument to $near $maxDistance(operand, valueSelector) { if (!valueSelector.$near) { throw new MiniMongoQueryError('$maxDistance needs a $near'); } return everythingMatcher; }, $all(operand, valueSelector, matcher) { if (!Array.isArray(operand)) { throw new MiniMongoQueryError('$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 new MiniMongoQueryError('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 new MiniMongoQueryError('$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 new MiniMongoQueryError('$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 new MiniMongoQueryError('$and/$or/$nor must be nonempty array'); } return selectors.map(subSelector => { if (!LocalCollection._isPlainObject(subSelector)) { throw new MiniMongoQueryError('$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 MiniMongoQueryError(`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, matcher && matcher._collator) ); } // 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. When a collator (Intl.Collator) is provided, // string equality uses locale-aware comparison. export function equalityElementMatcher(elementSelector, collator) { if (isOperatorObject(elementSelector)) { throw new MiniMongoQueryError('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, collator); } function everythingMatcher(docOrBranchedValues) { return {result: true}; } export function expandArraysInBranches(branches, skipTheArrays) { const branchesOut = []; branches.forEach(branch => { const thisIsArray = Array.isArray(branch.value); // We include the branch itself, *UNLESS* we it's an array that we're going // to iterate and we're told to skip arrays. (That's right, we include some // arrays even skipTheArrays is true: these are arrays that were found via // explicit numerical indices.) if (!(skipTheArrays && thisIsArray && !branch.dontIterate)) { branchesOut.push({arrayIndices: branch.arrayIndices, value: branch.value}); } if (thisIsArray && !branch.dontIterate) { branch.value.forEach((value, i) => { branchesOut.push({ arrayIndices: (branch.arrayIndices || []).concat(i), value }); }); } }); return branchesOut; } // Helpers for $bitsAllSet/$bitsAnySet/$bitsAllClear/$bitsAnyClear. function getOperandBitmask(operand, selector) { // numeric bitmask // You can provide a numeric bitmask to be matched against the operand field. // It must be representable as a non-negative 32-bit signed integer. // Otherwise, $bitsAllSet will return an error. if (Number.isInteger(operand) && operand >= 0) { return new Uint8Array(new Int32Array([operand]).buffer); } // bindata bitmask // You can also use an arbitrarily large BinData instance as a bitmask. if (EJSON.isBinary(operand)) { return new Uint8Array(operand.buffer); } // position list // If querying a list of bit positions, each <position> must be a non-negative // integer. Bit positions start at 0 from the least significant bit. if (Array.isArray(operand) && operand.every(x => Number.isInteger(x) && x >= 0)) { const buffer = new ArrayBuffer((Math.max(...operand) >> 3) + 1); const view = new Uint8Array(buffer); operand.forEach(x => { view[x >> 3] |= 1 << (x & 0x7); }); return view; } // bad operand throw new MiniMongoQueryError( `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 MiniMongoQueryError( `cannot infer query fields to set, both paths '${existingKey}' and '${key}' are matched` ); } else if (existingKey === key) { throw new MiniMongoQueryError( `cannot infer query fields to set, path '${key}' is matched twice` ); } }); document[key] = value; } // Returns a branched matcher that matches iff the given matcher does not. // Note that this implicitly "deMorganizes" the wrapped function. ie, it // means that ALL branch values need to fail to match innerBranchedMatcher. function invertBranchedMatcher(branchedMatcher) { return branchValues => { // We explicitly choose to strip arrayIndices here: it doesn't make sense to // say "update the array element that does not match something", at least // in mongo-land. return {result: !branchedMatcher(branchValues).result}; }; } export function isIndexable(obj) { return Array.isArray(obj) || LocalCollection._isPlainObject(obj); } export function isNumericKey(s) { return /^[0-9]+$/.test(s); } // Returns true if this is an object with at least one key and all keys begin // with $. Unless inconsistentOK is set, throws if some keys begin with $ and // others don't. export function isOperatorObject(valueSelector, inconsistentOK) { if (!LocalCollection._isPlainObject(valueSelector)) { return false; } let theseAreOperators = undefined; Object.keys(valueSelector).forEach(selKey => { const thisIsOperator = selKey.substr(0, 1) === '$' || selKey === 'diff'; if (theseAreOperators === undefined) { theseAreOperators = thisIsOperator; } else if (theseAreOperators !== thisIsOperator) { if (!inconsistentOK) { throw new MiniMongoQueryError( `Inconsistent operator: ${JSON.stringify(valueSelector)}` ); } theseAreOperators = false; } }); return !!theseAreOperators; // {} has no operators } // Helper for $lt/$gt/$lte/$gte. function makeInequality(cmpValueComparator) { return { compileElementSelector(operand, valueSelector, matcher) { // 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); const collator = matcher && matcher._collator; 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, collator)); }; }, }; } // makeLookupFunction(key) returns a lookup function. // // A lookup function takes in a document and returns an array of matching // branches. If no arrays are found while looking up the key, this array will // have exactly one branches (possibly 'undefined', if some segment of the key // was not found). // // If arrays are found in the middle, this can have more than one element, since // we 'branch'. When we 'branch', if there are more key segments to look up, // then we only pursue branches that are plain objects (not arrays or scalars). // This means we can actually end up with no branches! // // We do *NOT* branch on arrays that are found at the end (ie, at the last // dotted member of the key). We just return that array; if you want to // effectively 'branch' over the array's values, post-process the lookup // function with expandArraysInBranches. // // Each branch is an object with keys: // - value: the value at the branch // - dontIterate: an optional bool; if true, it means that 'value' is an array // that expandArraysInBranches should NOT expand. This specifically happens // when there is a numeric index in the key, and ensures the // perhaps-surprising MongoDB behavior where {'a.0': 5} does NOT // match {a: [[5]]}. // - arrayIndices: if any array indexing was done during lookup (either due to // explicit numeric indices or implicit branching), this will be an array of // the array indices used, from outermost to innermost; it is falsey or // absent if no array index is used. If an explicit numeric index is used, // the index will be followed in arrayIndices by the string 'x'. // // Note: arrayIndices is used for two purposes. First, it is used to // implement the '$' modifier feature, which only ever looks at its first // element. // // Second, it is used for sort key generation, which needs to be able to tell // the difference between different paths. Moreover, it needs to // differentiate between explicit and implicit branching, which is why // there's the somewhat hacky 'x' entry: this means that explicit and // implicit array lookups will have different full arrayIndices paths. (That // code only requires that different paths have different arrayIndices; it // doesn't actually 'parse' arrayIndices. As an alternative, arrayIndices // could contain objects with flags like 'implicit', but I think that only // makes the code surrounding them more complex.) // // (By the way, this field ends up getting passed around a lot without // cloning, so never mutate any arrayIndices field/var in this package!) // // // At the top level, you may only pass in a plain object or array. // // See the test 'minimongo - lookup' for some examples of what lookup functions // return. export function makeLookupFunction(key, options = {}) { const parts = key.split('.'); const firstPart = parts.length ? parts[0] : ''; const lookupRest = ( parts.length > 1 && makeLookupFunction(parts.slice(1).join('.'), options) ); function buildResult(arrayIndices, dontIterate, value) { return arrayIndices && arrayIndices.length ? dontIterate ? [{ arrayIndices, dontIterate, value }] : [{ arrayIndices, value }] : dontIterate ? [{ dontIterate, value }] : [{ value }]; } // Doc will always be a plain object or an array. // apply an explicit numeric index, an array. return (doc, arrayIndices) => { if (Array.isArray(doc)) { // If we're being asked to do an invalid lookup into an array (non-integer // or out-of-bounds), return no results (which is different from returning // a single undefined result, in that `null` equality checks won't match). if (!(isNumericKey(firstPart) && firstPart < doc.length)) { return []; } // Remember that we used this array index. Include an 'x' to indicate that // the previous index came from being considered as an explicit array // index (not branching). arrayIndices = arrayIndices ? arrayIndices.concat(+firstPart, 'x') : [+firstPart, 'x']; } // Do our first lookup. const firstLevel = doc[firstPart]; // If there is no deeper to dig, return what we found. // // If what we found is an array, most value selectors will choose to treat // the elements of the array as matchable values in their own right, but // that's done outside of the lookup function. (Exceptions to this are $size // and stuff relating to $elemMatch. eg, {a: {$size: 2}} does not match {a: // [[1, 2]]}.) // // That said, if we just did an *explicit* array lookup (on doc) to find // firstLevel, and firstLevel is an array too, we do NOT want value // selectors to iterate over it. eg, {'a.0': 5} does not match {a: [[5]]}. // So in that case, we mark the return value as 'don't iterate'. if (!lookupRest) { return buildResult( arrayIndices, Array.isArray(doc) && Array.isArray(firstLevel), firstLevel, ); } // We need to dig deeper. But if we can't, because what we've found is not // an array or plain object, we're done. If we just did a numeric index into // an array, we return nothing here (this is a change in Mongo 2.5 from // Mongo 2.4, where {'a.0.b': null} stopped matching {a: [5]}). Otherwise, // return a single `undefined` (which can, for example, match via equality // with `null`). if (!isIndexable(firstLevel)) { if (Array.isArray(doc)) { return []; } return buildResult(arrayIndices, false, undefined); } const result = []; const appendToResult = more => { result.push(...more); }; // Dig deeper: look up the rest of the parts on whatever we've found. // (lookupRest is smart enough to not try to do invalid lookups into // firstLevel if it's an array.) appendToResult(lookupRest(firstLevel, arrayIndices)); // If we found an array, then in *addition* to potentially treating the next // part as a literal integer lookup, we should also 'branch': try to look up // the rest of the parts on each array element in parallel. // // In this case, we *only* dig deeper into array elements that are plain // objects. (Recall that we only got this far if we have further to dig.) // This makes sense: we certainly don't dig deeper into non-indexable // objects. And it would be weird to dig into an array: it's simpler to have // a rule that explicit integer indexes only apply to an outer array, not to // an array you find after a branching search. // // In the special case of a numeric part in a *sort selector* (not a query // selector), we skip the branching: we ONLY allow the numeric part to mean // 'look up this index' in that case, not 'also look up this index in all // the elements of the array'. if (Array.isArray(firstLevel) && !(isNumericKey(parts[1]) && options.forSort)) { firstLevel.forEach((branch, arrayIndex) => { if (LocalCollection._isPlainObject(branch)) { appendToResult(lookupRest(branch, arrayIndices ? arrayIndices.concat(arrayIndex) : [arrayIndex])); } }); } return result; }; } // Object exported only for unit testing. // Use it to export private functions to test in Tinytest. MinimongoTest = {makeLookupFunction}; MinimongoError = (message, options = {}) => { if (typeof message === 'string' && options.field) { message += ` for field '${options.field}'`; } const error = new Error(message); error.name = 'MinimongoError'; return error; }; export function nothingMatcher(docOrBranchedValues) { return {result: false}; } // Takes an operator object (an object with $ keys) and returns a branched // matcher for it. function operatorBranchedMatcher(valueSelector, matcher, isRoot) { // Each valueSelector works separately on the various branches. So one // operator can match one branch and another can match another branch. This // is OK. const operatorMatchers = Object.keys(valueSelector).map(operator => { const operand = valueSelector[operator]; const simpleRange = ( ['$lt', '$lte', '$gt', '$gte'].includes(operator) && typeof operand === 'number' ); const simpleEquality = ( ['$ne', '$eq'].includes(operator) && operand !== Object(operand) ); const simpleInclusion = ( ['$in', '$nin'].includes(operator) && Array.isArray(operand) && !operand.some(x => x === Object(x)) ); if (!(simpleRange || simpleInclusion || simpleEquality)) { matcher._isSimple = false; } if (hasOwn.call(VALUE_OPERATORS, operator)) { return VALUE_OPERATORS[operator](operand, valueSelector, matcher, isRoot); } if (hasOwn.call(ELEMENT_OPERATORS, operator)) { const options = ELEMENT_OPERATORS[operator]; return convertElementMatcherToBranchedMatcher( options.compileElementSelector(operand, valueSelector, matcher), options ); } throw new MiniMongoQueryError(`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 MiniMongoQueryError(`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); }); } }