Merge pull request #8893 from radekmie/minimongo-without-underscore

Minimongo ES5/ES6 refactoring and performance improvements
This commit is contained in:
Ben Newman
2017-08-10 14:56:09 -04:00
committed by GitHub
30 changed files with 9286 additions and 8647 deletions

1376
packages/minimongo/common.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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 <em>cursor</em>
* 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 <em>cursor</em>
* 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
);
}
}

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -1,7 +0,0 @@
LocalCollection._IdMap = function () {
var self = this;
IdMap.call(self, MongoID.idStringify, MongoID.idParse);
};
Meteor._inherits(LocalCollection._IdMap, IdMap);

File diff suppressed because it is too large Load Diff

View File

@@ -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');
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
import './minimongo_common.js';

View File

@@ -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
};

View File

@@ -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;
}

View File

@@ -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");
});
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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');
});
}))();

View File

@@ -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 });
}
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -0,0 +1,2 @@
// ObserveHandle: the return value of a live query.
export default class ObserveHandle {}

View File

@@ -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',
'ejson',
'id-map',
'ordered-dict',
'tracker',
'mongo-id',
'random',
// This package is used to get diff results on arrays and objects
'diff-sequence',
'ecmascript'
]);
// 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');
});
Package.onTest(function (api) {
api.use('minimongo', ['client', 'server']);
api.use('test-helpers', 'client');
api.use([
'tinytest',
'underscore',
'ecmascript',
'ejson',
// This package is used for geo-location queries such as $near
'geojson-utils',
'id-map',
'mongo-id',
'ordered-dict',
'random',
'tracker',
'reactive-var',
'mongo-id',
'ecmascript'
'tracker'
]);
api.addFiles('minimongo_tests.js', 'client');
api.addFiles('wrap_transform_tests.js');
api.addFiles('minimongo_server_tests.js', 'server');
api.mainModule('minimongo_client.js', 'client');
api.mainModule('minimongo_server.js', 'server');
});
Package.onTest(api => {
api.use('minimongo');
api.use([
'ecmascript',
'ejson',
'mongo-id',
'ordered-dict',
'random',
'reactive-var',
'test-helpers',
'tinytest',
'tracker'
]);
api.addFiles('minimongo_tests.js');
api.addFiles('minimongo_tests_client.js', 'client');
api.addFiles('minimongo_tests_server.js', 'server');
});

View File

@@ -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");
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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 || {};
export default class Sorter {
constructor(spec, options = {}) {
this._sortSpecParts = [];
this._sortFunction = null;
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
});
};
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");
const addSpecPart = (path, ascending) => {
if (!path) {
throw Error('sort keys must be non-empty');
}
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)}`);
}
// 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);
}
} 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);
}
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 (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');
},
// 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');
}
// 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;
}
if (self._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 minKey;
},
_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;
_generateKeysFromDoc(doc, cb) {
if (this._sortSpecParts.length === 0) {
throw new Error('can\'t generate keys without a spec');
}
if (self._sortSpecParts.length === 0)
throw new Error("can't generate keys without a spec");
const pathFromIndices = indices => `${indices.join(',')},`;
let knownPaths = null;
// maps index -> ({'' -> value} or {path -> value})
var valuesByIndexAndPath = [];
var pathFromIndices = function (indices) {
return indices.join(',') + ',';
};
var knownPaths = null;
_.each(self._sortSpecParts, function (spec, whichField) {
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");
}
return self._keyComparator(key1, key2);
},
// 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;
};
},
}
// Returns a comparator that represents the sort specification (but not
// including a possible geoquery distance tie-breaker).
_getBaseComparator: function () {
var self = this;
if (self._sortFunction)
return self._sortFunction;
_getBaseComparator() {
if (this._sortFunction) {
return this._sortFunction;
}
// 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 (!this._sortSpecParts.length) {
return (doc1, doc2) => 0;
}
return function (doc1, doc2) {
var key1 = self._getMinKeyFromDoc(doc1);
var key2 = self._getMinKeyFromDoc(doc2);
return self._compareKeys(key1, key2);
return (doc1, doc2) => {
const key1 = this._getMinKeyFromDoc(doc1);
const key2 = this._getMinKeyFromDoc(doc2);
return this._compareKeys(key1, key2);
};
},
}
// 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;
this._generateKeysFromDoc(doc, key => {
if (!this._keyCompatibleWithSelector(key)) {
return;
}
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 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;
};
};
}

View File

@@ -1,5 +0,0 @@
Minimongo.Sorter.prototype.combineIntoProjection = function (projection) {
var self = this;
var specPaths = Minimongo._pathsElidingNumericKeys(self._getPaths());
return combineImportantPathsIntoProjection(specPaths, projection);
};

View File

@@ -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.');
}
}

View File

@@ -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;
});
}
};

View File

@@ -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;
};

View File

@@ -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();
});