packages/minimongo/local_collection.js
import Cursor from './cursor.js';
import ObserveHandle from './observe_handle.js';
import {
hasOwn,
isIndexable,
isNumericKey,
isOperatorObject,
populateDocumentWithQueryFields,
projectionDetails,
} from './common.js';
// XXX type checking on selectors (graceful error if malformed)
// LocalCollection: a set of documents that supports queries and modifiers.
export default class LocalCollection {
constructor(name) {
this.name = name;
// _id -> document (also containing id)
this._docs = new LocalCollection._IdMap;
this._observeQueue = new Meteor._SynchronousQueue();
this.next_qid = 1; // live query id generator
// qid -> live query object. keys:
// ordered: bool. ordered queries have addedBefore/movedBefore callbacks.
// results: array (ordered) or object (unordered) of current results
// (aliased with this._docs!)
// resultsSnapshot: snapshot of results. null if not paused.
// cursor: Cursor object for the query.
// selector, sorter, (callbacks): functions
this.queries = Object.create(null);
// null if not saving originals; an IdMap from id to original document value
// if saving originals. See comments before saveOriginals().
this._savedOriginals = null;
// True when observers are paused and we should not send callbacks.
this.paused = false;
}
countDocuments(selector, options) {
return this.find(selector ?? {}, options).countAsync();
}
estimatedDocumentCount(options) {
return this.find({}, options).countAsync();
}
// options may include sort, skip, limit, reactive
// sort may be any of these forms:
// {a: 1, b: -1}
// [["a", "asc"], ["b", "desc"]]
// ["a", ["b", "desc"]]
// (in the first form you're beholden to key enumeration order in
// your javascript VM)
//
// reactive: if given, and false, don't register with Tracker (default
// is true)
//
// XXX possibly should support retrieving a subset of fields? and
// have it be a hint (ignored on the client, when not copying the
// doc?)
//
// XXX sort does not yet support subkeys ('a.b') .. fix that!
// XXX add one more sort form: "key"
// XXX tests
find(selector, options) {
// default syntax for everything is to omit the selector argument.
// but if selector is explicitly passed in as false or undefined, we
// want a selector that matches nothing.
if (arguments.length === 0) {
selector = {};
}
return new LocalCollection.Cursor(this, selector, options);
}
findOne(selector, options = {}) {
if (arguments.length === 0) {
selector = {};
}
// NOTE: by setting limit 1 here, we end up using very inefficient
// code that recomputes the whole query on each update. The upside is
// that when you reactively depend on a findOne you only get
// invalidated when the found object changes, not any object in the
// collection. Most findOne will be by id, which has a fast path, so
// this might not be a big deal. In most cases, invalidation causes
// the called to re-query anyway, so this should be a net performance
// improvement.
options.limit = 1;
return this.find(selector, options).fetch()[0];
}
// XXX possibly enforce that 'undefined' does not appear (we assume
// this in our handling of null and $exists)
insert(doc, callback) {
doc = EJSON.clone(doc);
assertHasValidFieldNames(doc);
// if you really want to use ObjectIDs, set this global.
// Mongo.Collection specifies its own ids and does not use this code.
if (!hasOwn.call(doc, '_id')) {
doc._id = LocalCollection._useOID ? new MongoID.ObjectID() : Random.id();
}
const id = doc._id;
if (this._docs.has(id)) {
throw MinimongoError(`Duplicate _id '${id}'`);
}
this._saveOriginal(id, undefined);
this._docs.set(id, doc);
const queriesToRecompute = [];
// trigger live queries that match
Object.keys(this.queries).forEach(qid => {
const query = this.queries[qid];
if (query.dirty) {
return;
}
const matchResult = query.matcher.documentMatches(doc);
if (matchResult.result) {
if (query.distances && matchResult.distance !== undefined) {
query.distances.set(id, matchResult.distance);
}
if (query.cursor.skip || query.cursor.limit) {
queriesToRecompute.push(qid);
} else {
LocalCollection._insertInResults(query, doc);
}
}
});
queriesToRecompute.forEach(qid => {
if (this.queries[qid]) {
this._recomputeResults(this.queries[qid]);
}
});
this._observeQueue.drain();
// Defer because the caller likely doesn't expect the callback to be run
// immediately.
if (callback) {
Meteor.defer(() => {
callback(null, id);
});
}
return id;
}
// Pause the observers. No callbacks from observers will fire until
// 'resumeObservers' is called.
pauseObservers() {
// No-op if already paused.
if (this.paused) {
return;
}
// Set the 'paused' flag such that new observer messages don't fire.
this.paused = true;
// Take a snapshot of the query results for each query.
Object.keys(this.queries).forEach(qid => {
const query = this.queries[qid];
query.resultsSnapshot = EJSON.clone(query.results);
});
}
remove(selector, callback) {
// Easy special case: if we're not calling observeChanges callbacks and
// we're not saving originals and we got asked to remove everything, then
// just empty everything directly.
if (this.paused && !this._savedOriginals && EJSON.equals(selector, {})) {
const result = this._docs.size();
this._docs.clear();
Object.keys(this.queries).forEach(qid => {
const query = this.queries[qid];
if (query.ordered) {
query.results = [];
} else {
query.results.clear();
}
});
if (callback) {
Meteor.defer(() => {
callback(null, result);
});
}
return result;
}
const matcher = new Minimongo.Matcher(selector);
const remove = [];
this._eachPossiblyMatchingDoc(selector, (doc, id) => {
if (matcher.documentMatches(doc).result) {
remove.push(id);
}
});
const queriesToRecompute = [];
const queryRemove = [];
for (let i = 0; i < remove.length; i++) {
const removeId = remove[i];
const removeDoc = this._docs.get(removeId);
Object.keys(this.queries).forEach(qid => {
const query = this.queries[qid];
if (query.dirty) {
return;
}
if (query.matcher.documentMatches(removeDoc).result) {
if (query.cursor.skip || query.cursor.limit) {
queriesToRecompute.push(qid);
} else {
queryRemove.push({qid, doc: removeDoc});
}
}
});
this._saveOriginal(removeId, removeDoc);
this._docs.remove(removeId);
}
// run live query callbacks _after_ we've removed the documents.
queryRemove.forEach(remove => {
const query = this.queries[remove.qid];
if (query) {
query.distances && query.distances.remove(remove.doc._id);
LocalCollection._removeFromResults(query, remove.doc);
}
});
queriesToRecompute.forEach(qid => {
const query = this.queries[qid];
if (query) {
this._recomputeResults(query);
}
});
this._observeQueue.drain();
const result = remove.length;
if (callback) {
Meteor.defer(() => {
callback(null, result);
});
}
return result;
}
// Resume the observers. Observers immediately receive change
// notifications to bring them to the current state of the
// database. Note that this is not just replaying all the changes that
// happened during the pause, it is a smarter 'coalesced' diff.
resumeObservers() {
// No-op if not paused.
if (!this.paused) {
return;
}
// Unset the 'paused' flag. Make sure to do this first, otherwise
// observer methods won't actually fire when we trigger them.
this.paused = false;
Object.keys(this.queries).forEach(qid => {
const query = this.queries[qid];
if (query.dirty) {
query.dirty = false;
// re-compute results will perform `LocalCollection._diffQueryChanges`
// automatically.
this._recomputeResults(query, query.resultsSnapshot);
} else {
// Diff the current results against the snapshot and send to observers.
// pass the query object for its observer callbacks.
LocalCollection._diffQueryChanges(
query.ordered,
query.resultsSnapshot,
query.results,
query,
{projectionFn: query.projectionFn}
);
}
query.resultsSnapshot = null;
});
this._observeQueue.drain();
}
retrieveOriginals() {
if (!this._savedOriginals) {
throw new Error('Called retrieveOriginals without saveOriginals');
}
const originals = this._savedOriginals;
this._savedOriginals = null;
return originals;
}
// To track what documents are affected by a piece of code, call
// saveOriginals() before it and retrieveOriginals() after it.
// retrieveOriginals returns an object whose keys are the ids of the documents
// that were affected since the call to saveOriginals(), and the values are
// equal to the document's contents at the time of saveOriginals. (In the case
// of an inserted document, undefined is the value.) You must alternate
// between calls to saveOriginals() and retrieveOriginals().
saveOriginals() {
if (this._savedOriginals) {
throw new Error('Called saveOriginals twice without retrieveOriginals');
}
this._savedOriginals = new LocalCollection._IdMap;
}
// XXX atomicity: if multi is true, and one modification fails, do
// we rollback the whole operation, or what?
update(selector, mod, options, callback) {
if (! callback && options instanceof Function) {
callback = options;
options = null;
}
if (!options) {
options = {};
}
const matcher = new Minimongo.Matcher(selector, true);
// Save the original results of any query that we might need to
// _recomputeResults on, because _modifyAndNotify will mutate the objects in
// it. (We don't need to save the original results of paused queries because
// they already have a resultsSnapshot and we won't be diffing in
// _recomputeResults.)
const qidToOriginalResults = {};
// We should only clone each document once, even if it appears in multiple
// queries
const docMap = new LocalCollection._IdMap;
const idsMatched = LocalCollection._idsMatchedBySelector(selector);
Object.keys(this.queries).forEach(qid => {
const query = this.queries[qid];
if ((query.cursor.skip || query.cursor.limit) && ! this.paused) {
// Catch the case of a reactive `count()` on a cursor with skip
// or limit, which registers an unordered observe. This is a
// pretty rare case, so we just clone the entire result set with
// no optimizations for documents that appear in these result
// sets and other queries.
if (query.results instanceof LocalCollection._IdMap) {
qidToOriginalResults[qid] = query.results.clone();
return;
}
if (!(query.results instanceof Array)) {
throw new Error('Assertion failed: query.results not an array');
}
// Clones a document to be stored in `qidToOriginalResults`
// because it may be modified before the new and old result sets
// are diffed. But if we know exactly which document IDs we're
// going to modify, then we only need to clone those.
const memoizedCloneIfNeeded = doc => {
if (docMap.has(doc._id)) {
return docMap.get(doc._id);
}
const docToMemoize = (
idsMatched &&
!idsMatched.some(id => EJSON.equals(id, doc._id))
) ? doc : EJSON.clone(doc);
docMap.set(doc._id, docToMemoize);
return docToMemoize;
};
qidToOriginalResults[qid] = query.results.map(memoizedCloneIfNeeded);
}
});
const recomputeQids = {};
let updateCount = 0;
this._eachPossiblyMatchingDoc(selector, (doc, id) => {
const queryResult = matcher.documentMatches(doc);
if (queryResult.result) {
// XXX Should we save the original even if mod ends up being a no-op?
this._saveOriginal(id, doc);
this._modifyAndNotify(
doc,
mod,
recomputeQids,
queryResult.arrayIndices
);
++updateCount;
if (!options.multi) {
return false; // break
}
}
return true;
});
Object.keys(recomputeQids).forEach(qid => {
const query = this.queries[qid];
if (query) {
this._recomputeResults(query, qidToOriginalResults[qid]);
}
});
this._observeQueue.drain();
// If we are doing an upsert, and we didn't modify any documents yet, then
// it's time to do an insert. Figure out what document we are inserting, and
// generate an id for it.
let insertedId;
if (updateCount === 0 && options.upsert) {
const doc = LocalCollection._createUpsertDocument(selector, mod);
if (! doc._id && options.insertedId) {
doc._id = options.insertedId;
}
insertedId = this.insert(doc);
updateCount = 1;
}
// Return the number of affected documents, or in the upsert case, an object
// containing the number of affected docs and the id of the doc that was
// inserted, if any.
let result;
if (options._returnObject) {
result = {numberAffected: updateCount};
if (insertedId !== undefined) {
result.insertedId = insertedId;
}
} else {
result = updateCount;
}
if (callback) {
Meteor.defer(() => {
callback(null, result);
});
}
return result;
}
// A convenience wrapper on update. LocalCollection.upsert(sel, mod) is
// equivalent to LocalCollection.update(sel, mod, {upsert: true,
// _returnObject: true}).
upsert(selector, mod, options, callback) {
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
return this.update(
selector,
mod,
Object.assign({}, options, {upsert: true, _returnObject: true}),
callback
);
}
// Iterates over a subset of documents that could match selector; calls
// fn(doc, id) on each of them. Specifically, if selector specifies
// specific _id's, it only looks at those. doc is *not* cloned: it is the
// same object that is in _docs.
_eachPossiblyMatchingDoc(selector, fn) {
const specificIds = LocalCollection._idsMatchedBySelector(selector);
if (specificIds) {
specificIds.some(id => {
const doc = this._docs.get(id);
if (doc) {
return fn(doc, id) === false;
}
});
} else {
this._docs.forEach(fn);
}
}
_modifyAndNotify(doc, mod, recomputeQids, arrayIndices) {
const matched_before = {};
Object.keys(this.queries).forEach(qid => {
const query = this.queries[qid];
if (query.dirty) {
return;
}
if (query.ordered) {
matched_before[qid] = query.matcher.documentMatches(doc).result;
} else {
// Because we don't support skip or limit (yet) in unordered queries, we
// can just do a direct lookup.
matched_before[qid] = query.results.has(doc._id);
}
});
const old_doc = EJSON.clone(doc);
LocalCollection._modify(doc, mod, {arrayIndices});
Object.keys(this.queries).forEach(qid => {
const query = this.queries[qid];
if (query.dirty) {
return;
}
const afterMatch = query.matcher.documentMatches(doc);
const after = afterMatch.result;
const before = matched_before[qid];
if (after && query.distances && afterMatch.distance !== undefined) {
query.distances.set(doc._id, afterMatch.distance);
}
if (query.cursor.skip || query.cursor.limit) {
// We need to recompute any query where the doc may have been in the
// cursor's window either before or after the update. (Note that if skip
// or limit is set, "before" and "after" being true do not necessarily
// mean that the document is in the cursor's output after skip/limit is
// applied... but if they are false, then the document definitely is NOT
// in the output. So it's safe to skip recompute if neither before or
// after are true.)
if (before || after) {
recomputeQids[qid] = true;
}
} else if (before && !after) {
LocalCollection._removeFromResults(query, doc);
} else if (!before && after) {
LocalCollection._insertInResults(query, doc);
} else if (before && after) {
LocalCollection._updateInResults(query, doc, old_doc);
}
});
}
// Recomputes the results of a query and runs observe callbacks for the
// difference between the previous results and the current results (unless
// paused). Used for skip/limit queries.
//
// When this is used by insert or remove, it can just use query.results for
// the old results (and there's no need to pass in oldResults), because these
// operations don't mutate the documents in the collection. Update needs to
// pass in an oldResults which was deep-copied before the modifier was
// applied.
//
// oldResults is guaranteed to be ignored if the query is not paused.
_recomputeResults(query, oldResults) {
if (this.paused) {
// There's no reason to recompute the results now as we're still paused.
// By flagging the query as "dirty", the recompute will be performed
// when resumeObservers is called.
query.dirty = true;
return;
}
if (!this.paused && !oldResults) {
oldResults = query.results;
}
if (query.distances) {
query.distances.clear();
}
query.results = query.cursor._getRawObjects({
distances: query.distances,
ordered: query.ordered
});
if (!this.paused) {
LocalCollection._diffQueryChanges(
query.ordered,
oldResults,
query.results,
query,
{projectionFn: query.projectionFn}
);
}
}
_saveOriginal(id, doc) {
// Are we even trying to save originals?
if (!this._savedOriginals) {
return;
}
// Have we previously mutated the original (and so 'doc' is not actually
// original)? (Note the 'has' check rather than truth: we store undefined
// here for inserted docs!)
if (this._savedOriginals.has(id)) {
return;
}
this._savedOriginals.set(id, EJSON.clone(doc));
}
}
LocalCollection.Cursor = Cursor;
LocalCollection.ObserveHandle = ObserveHandle;
// XXX maybe move these into another ObserveHelpers package or something
// _CachingChangeObserver is an object which receives observeChanges callbacks
// and keeps a cache of the current cursor state up to date in this.docs. Users
// of this class should read the docs field but not modify it. You should pass
// the "applyChange" field as the callbacks to the underlying observeChanges
// call. Optionally, you can specify your own observeChanges callbacks which are
// invoked immediately before the docs field is updated; this object is made
// available as `this` to those callbacks.
LocalCollection._CachingChangeObserver = class _CachingChangeObserver {
constructor(options = {}) {
const orderedFromCallbacks = (
options.callbacks &&
LocalCollection._observeChangesCallbacksAreOrdered(options.callbacks)
);
if (hasOwn.call(options, 'ordered')) {
this.ordered = options.ordered;
if (options.callbacks && options.ordered !== orderedFromCallbacks) {
throw Error('ordered option doesn\'t match callbacks');
}
} else if (options.callbacks) {
this.ordered = orderedFromCallbacks;
} else {
throw Error('must provide ordered or callbacks');
}
const callbacks = options.callbacks || {};
if (this.ordered) {
this.docs = new OrderedDict(MongoID.idStringify);
this.applyChange = {
addedBefore: (id, fields, before) => {
// Take a shallow copy since the top-level properties can be changed
const doc = { ...fields };
doc._id = id;
if (callbacks.addedBefore) {
callbacks.addedBefore.call(this, id, EJSON.clone(fields), before);
}
// This line triggers if we provide added with movedBefore.
if (callbacks.added) {
callbacks.added.call(this, id, EJSON.clone(fields));
}
// XXX could `before` be a falsy ID? Technically
// idStringify seems to allow for them -- though
// OrderedDict won't call stringify on a falsy arg.
this.docs.putBefore(id, doc, before || null);
},
movedBefore: (id, before) => {
const doc = this.docs.get(id);
if (callbacks.movedBefore) {
callbacks.movedBefore.call(this, id, before);
}
this.docs.moveBefore(id, before || null);
},
};
} else {
this.docs = new LocalCollection._IdMap;
this.applyChange = {
added: (id, fields) => {
// Take a shallow copy since the top-level properties can be changed
const doc = { ...fields };
if (callbacks.added) {
callbacks.added.call(this, id, EJSON.clone(fields));
}
doc._id = id;
this.docs.set(id, doc);
},
};
}
// The methods in _IdMap and OrderedDict used by these callbacks are
// identical.
this.applyChange.changed = (id, fields) => {
const doc = this.docs.get(id);
if (!doc) {
throw new Error(`Unknown id for changed: ${id}`);
}
if (callbacks.changed) {
callbacks.changed.call(this, id, EJSON.clone(fields));
}
DiffSequence.applyChanges(doc, fields);
};
this.applyChange.removed = id => {
if (callbacks.removed) {
callbacks.removed.call(this, id);
}
this.docs.remove(id);
};
}
};
LocalCollection._IdMap = class _IdMap extends IdMap {
constructor() {
super(MongoID.idStringify, MongoID.idParse);
}
};
// Wrap a transform function to return objects that have the _id field
// of the untransformed document. This ensures that subsystems such as
// the observe-sequence package that call `observe` can keep track of
// the documents identities.
//
// - Require that it returns objects
// - If the return value has an _id field, verify that it matches the
// original _id field
// - If the return value doesn't have an _id field, add it back.
LocalCollection.wrapTransform = transform => {
if (!transform) {
return null;
}
// No need to doubly-wrap transforms.
if (transform.__wrappedTransform__) {
return transform;
}
const wrapped = doc => {
if (!hasOwn.call(doc, '_id')) {
// XXX do we ever have a transform on the oplog's collection? because that
// collection has no _id.
throw new Error('can only transform documents with _id');
}
const id = doc._id;
// XXX consider making tracker a weak dependency and checking
// Package.tracker here
const transformed = Tracker.nonreactive(() => transform(doc));
if (!LocalCollection._isPlainObject(transformed)) {
throw new Error('transform must return object');
}
if (hasOwn.call(transformed, '_id')) {
if (!EJSON.equals(transformed._id, id)) {
throw new Error('transformed document can\'t have different _id');
}
} else {
transformed._id = id;
}
return transformed;
};
wrapped.__wrappedTransform__ = true;
return wrapped;
};
// XXX the sorted-query logic below is laughably inefficient. we'll
// need to come up with a better datastructure for this.
//
// XXX the logic for observing with a skip or a limit is even more
// laughably inefficient. we recompute the whole results every time!
// This binary search puts a value between any equal values, and the first
// lesser value.
LocalCollection._binarySearch = (cmp, array, value) => {
let first = 0;
let range = array.length;
while (range > 0) {
const halfRange = Math.floor(range / 2);
if (cmp(value, array[first + halfRange]) >= 0) {
first += halfRange + 1;
range -= halfRange + 1;
} else {
range = halfRange;
}
}
return first;
};
LocalCollection._checkSupportedProjection = fields => {
if (fields !== Object(fields) || Array.isArray(fields)) {
throw MinimongoError('fields option must be an object');
}
Object.keys(fields).forEach(keyPath => {
if (keyPath.split('.').includes('$')) {
throw MinimongoError(
'Minimongo doesn\'t support $ operator in projections yet.'
);
}
const value = fields[keyPath];
if (typeof value === 'object' &&
['$elemMatch', '$meta', '$slice'].some(key =>
hasOwn.call(value, key)
)) {
throw MinimongoError(
'Minimongo doesn\'t support operators in projections yet.'
);
}
if (![1, 0, true, false].includes(value)) {
throw MinimongoError(
'Projection values should be one of 1, 0, true, or false'
);
}
});
};
// Knows how to compile a fields projection to a predicate function.
// @returns - Function: a closure that filters out an object according to the
// fields projection rules:
// @param obj - Object: MongoDB-styled document
// @returns - Object: a document with the fields filtered out
// according to projection rules. Doesn't retain subfields
// of passed argument.
LocalCollection._compileProjection = fields => {
LocalCollection._checkSupportedProjection(fields);
const _idProjection = fields._id === undefined ? true : fields._id;
const details = projectionDetails(fields);
// returns transformed doc according to ruleTree
const transform = (doc, ruleTree) => {
// Special case for "sets"
if (Array.isArray(doc)) {
return doc.map(subdoc => transform(subdoc, ruleTree));
}
const result = details.including ? {} : EJSON.clone(doc);
Object.keys(ruleTree).forEach(key => {
if (doc == null || !hasOwn.call(doc, key)) {
return;
}
const rule = ruleTree[key];
if (rule === Object(rule)) {
// For sub-objects/subsets we branch
if (doc[key] === Object(doc[key])) {
result[key] = transform(doc[key], rule);
}
} else if (details.including) {
// Otherwise we don't even touch this subfield
result[key] = EJSON.clone(doc[key]);
} else {
delete result[key];
}
});
return doc != null ? result : doc;
};
return doc => {
const result = transform(doc, details.tree);
if (_idProjection && hasOwn.call(doc, '_id')) {
result._id = doc._id;
}
if (!_idProjection && hasOwn.call(result, '_id')) {
delete result._id;
}
return result;
};
};
// Calculates the document to insert in case we're doing an upsert and the
// selector does not match any elements
LocalCollection._createUpsertDocument = (selector, modifier) => {
const selectorDocument = populateDocumentWithQueryFields(selector);
const isModify = LocalCollection._isModificationMod(modifier);
const newDoc = {};
if (selectorDocument._id) {
newDoc._id = selectorDocument._id;
delete selectorDocument._id;
}
// This double _modify call is made to help with nested properties (see issue
// #8631). We do this even if it's a replacement for validation purposes (e.g.
// ambiguous id's)
LocalCollection._modify(newDoc, {$set: selectorDocument});
LocalCollection._modify(newDoc, modifier, {isInsert: true});
if (isModify) {
return newDoc;
}
// Replacement can take _id from query document
const replacement = Object.assign({}, modifier);
if (newDoc._id) {
replacement._id = newDoc._id;
}
return replacement;
};
LocalCollection._diffObjects = (left, right, callbacks) => {
return DiffSequence.diffObjects(left, right, callbacks);
};
// ordered: bool.
// old_results and new_results: collections of documents.
// if ordered, they are arrays.
// if unordered, they are IdMaps
LocalCollection._diffQueryChanges = (ordered, oldResults, newResults, observer, options) =>
DiffSequence.diffQueryChanges(ordered, oldResults, newResults, observer, options)
;
LocalCollection._diffQueryOrderedChanges = (oldResults, newResults, observer, options) =>
DiffSequence.diffQueryOrderedChanges(oldResults, newResults, observer, options)
;
LocalCollection._diffQueryUnorderedChanges = (oldResults, newResults, observer, options) =>
DiffSequence.diffQueryUnorderedChanges(oldResults, newResults, observer, options)
;
LocalCollection._findInOrderedResults = (query, doc) => {
if (!query.ordered) {
throw new Error('Can\'t call _findInOrderedResults on unordered query');
}
for (let i = 0; i < query.results.length; i++) {
if (query.results[i] === doc) {
return i;
}
}
throw Error('object missing from query');
};
// If this is a selector which explicitly constrains the match by ID to a finite
// number of documents, returns a list of their IDs. Otherwise returns
// null. Note that the selector may have other restrictions so it may not even
// match those document! We care about $in and $and since those are generated
// access-controlled update and remove.
LocalCollection._idsMatchedBySelector = selector => {
// Is the selector just an ID?
if (LocalCollection._selectorIsId(selector)) {
return [selector];
}
if (!selector) {
return null;
}
// Do we have an _id clause?
if (hasOwn.call(selector, '_id')) {
// Is the _id clause just an ID?
if (LocalCollection._selectorIsId(selector._id)) {
return [selector._id];
}
// Is the _id clause {_id: {$in: ["x", "y", "z"]}}?
if (selector._id
&& Array.isArray(selector._id.$in)
&& selector._id.$in.length
&& selector._id.$in.every(LocalCollection._selectorIsId)) {
return selector._id.$in;
}
return null;
}
// If this is a top-level $and, and any of the clauses constrain their
// documents, then the whole selector is constrained by any one clause's
// constraint. (Well, by their intersection, but that seems unlikely.)
if (Array.isArray(selector.$and)) {
for (let i = 0; i < selector.$and.length; ++i) {
const subIds = LocalCollection._idsMatchedBySelector(selector.$and[i]);
if (subIds) {
return subIds;
}
}
}
return null;
};
LocalCollection._insertInResults = (query, doc) => {
const fields = EJSON.clone(doc);
delete fields._id;
if (query.ordered) {
if (!query.sorter) {
query.addedBefore(doc._id, query.projectionFn(fields), null);
query.results.push(doc);
} else {
const i = LocalCollection._insertInSortedList(
query.sorter.getComparator({distances: query.distances}),
query.results,
doc
);
let next = query.results[i + 1];
if (next) {
next = next._id;
} else {
next = null;
}
query.addedBefore(doc._id, query.projectionFn(fields), next);
}
query.added(doc._id, query.projectionFn(fields));
} else {
query.added(doc._id, query.projectionFn(fields));
query.results.set(doc._id, doc);
}
};
LocalCollection._insertInSortedList = (cmp, array, value) => {
if (array.length === 0) {
array.push(value);
return 0;
}
const i = LocalCollection._binarySearch(cmp, array, value);
array.splice(i, 0, value);
return i;
};
LocalCollection._isModificationMod = mod => {
let isModify = false;
let isReplace = false;
Object.keys(mod).forEach(key => {
if (key.substr(0, 1) === '$') {
isModify = true;
} else {
isReplace = true;
}
});
if (isModify && isReplace) {
throw new Error(
'Update parameter cannot have both modifier and non-modifier fields.'
);
}
return isModify;
};
// XXX maybe this should be EJSON.isObject, though EJSON doesn't know about
// RegExp
// XXX note that _type(undefined) === 3!!!!
LocalCollection._isPlainObject = x => {
return x && LocalCollection._f._type(x) === 3;
};
// XXX need a strategy for passing the binding of $ into this
// function, from the compiled selector
//
// maybe just {key.up.to.just.before.dollarsign: array_index}
//
// XXX atomicity: if one modification fails, do we roll back the whole
// change?
//
// options:
// - isInsert is set when _modify is being called to compute the document to
// insert as part of an upsert operation. We use this primarily to figure
// out when to set the fields in $setOnInsert, if present.
LocalCollection._modify = (doc, modifier, options = {}) => {
if (!LocalCollection._isPlainObject(modifier)) {
throw MinimongoError('Modifier must be an object');
}
// Make sure the caller can't mutate our data structures.
modifier = EJSON.clone(modifier);
const isModifier = isOperatorObject(modifier);
const newDoc = isModifier ? EJSON.clone(doc) : modifier;
if (isModifier) {
// apply modifiers to the doc.
Object.keys(modifier).forEach(operator => {
// Treat $setOnInsert as $set if this is an insert.
const setOnInsert = options.isInsert && operator === '$setOnInsert';
const modFunc = MODIFIERS[setOnInsert ? '$set' : operator];
const operand = modifier[operator];
if (!modFunc) {
throw MinimongoError(`Invalid modifier specified ${operator}`);
}
Object.keys(operand).forEach(keypath => {
const arg = operand[keypath];
if (keypath === '') {
throw MinimongoError('An empty update path is not valid.');
}
const keyparts = keypath.split('.');
if (!keyparts.every(Boolean)) {
throw MinimongoError(
`The update path '${keypath}' contains an empty field name, ` +
'which is not allowed.'
);
}
const target = findModTarget(newDoc, keyparts, {
arrayIndices: options.arrayIndices,
forbidArray: operator === '$rename',
noCreate: NO_CREATE_MODIFIERS[operator]
});
modFunc(target, keyparts.pop(), arg, keypath, newDoc);
});
});
if (doc._id && !EJSON.equals(doc._id, newDoc._id)) {
throw MinimongoError(
`After applying the update to the document {_id: "${doc._id}", ...},` +
' the (immutable) field \'_id\' was found to have been altered to ' +
`_id: "${newDoc._id}"`
);
}
} else {
if (doc._id && modifier._id && !EJSON.equals(doc._id, modifier._id)) {
throw MinimongoError(
`The _id field cannot be changed from {_id: "${doc._id}"} to ` +
`{_id: "${modifier._id}"}`
);
}
// replace the whole document
assertHasValidFieldNames(modifier);
}
// move new document into place.
Object.keys(doc).forEach(key => {
// Note: this used to be for (var key in doc) however, this does not
// work right in Opera. Deleting from a doc while iterating over it
// would sometimes cause opera to skip some keys.
if (key !== '_id') {
delete doc[key];
}
});
Object.keys(newDoc).forEach(key => {
doc[key] = newDoc[key];
});
};
LocalCollection._observeFromObserveChanges = (cursor, observeCallbacks) => {
const transform = cursor.getTransform() || (doc => doc);
let suppressed = !!observeCallbacks._suppress_initial;
let observeChangesCallbacks;
if (LocalCollection._observeCallbacksAreOrdered(observeCallbacks)) {
// The "_no_indices" option sets all index arguments to -1 and skips the
// linear scans required to generate them. This lets observers that don't
// need absolute indices benefit from the other features of this API --
// relative order, transforms, and applyChanges -- without the speed hit.
const indices = !observeCallbacks._no_indices;
observeChangesCallbacks = {
addedBefore(id, fields, before) {
if (suppressed || !(observeCallbacks.addedAt || observeCallbacks.added)) {
return;
}
const doc = transform(Object.assign(fields, {_id: id}));
if (observeCallbacks.addedAt) {
observeCallbacks.addedAt(
doc,
indices
? before
? this.docs.indexOf(before)
: this.docs.size()
: -1,
before
);
} else {
observeCallbacks.added(doc);
}
},
changed(id, fields) {
if (!(observeCallbacks.changedAt || observeCallbacks.changed)) {
return;
}
let doc = EJSON.clone(this.docs.get(id));
if (!doc) {
throw new Error(`Unknown id for changed: ${id}`);
}
const oldDoc = transform(EJSON.clone(doc));
DiffSequence.applyChanges(doc, fields);
if (observeCallbacks.changedAt) {
observeCallbacks.changedAt(
transform(doc),
oldDoc,
indices ? this.docs.indexOf(id) : -1
);
} else {
observeCallbacks.changed(transform(doc), oldDoc);
}
},
movedBefore(id, before) {
if (!observeCallbacks.movedTo) {
return;
}
const from = indices ? this.docs.indexOf(id) : -1;
let to = indices
? before
? this.docs.indexOf(before)
: this.docs.size()
: -1;
// When not moving backwards, adjust for the fact that removing the
// document slides everything back one slot.
if (to > from) {
--to;
}
observeCallbacks.movedTo(
transform(EJSON.clone(this.docs.get(id))),
from,
to,
before || null
);
},
removed(id) {
if (!(observeCallbacks.removedAt || observeCallbacks.removed)) {
return;
}
// technically maybe there should be an EJSON.clone here, but it's about
// to be removed from this.docs!
const doc = transform(this.docs.get(id));
if (observeCallbacks.removedAt) {
observeCallbacks.removedAt(doc, indices ? this.docs.indexOf(id) : -1);
} else {
observeCallbacks.removed(doc);
}
},
};
} else {
observeChangesCallbacks = {
added(id, fields) {
if (!suppressed && observeCallbacks.added) {
observeCallbacks.added(transform(Object.assign(fields, {_id: id})));
}
},
changed(id, fields) {
if (observeCallbacks.changed) {
const oldDoc = this.docs.get(id);
const doc = EJSON.clone(oldDoc);
DiffSequence.applyChanges(doc, fields);
observeCallbacks.changed(
transform(doc),
transform(EJSON.clone(oldDoc))
);
}
},
removed(id) {
if (observeCallbacks.removed) {
observeCallbacks.removed(transform(this.docs.get(id)));
}
},
};
}
const changeObserver = new LocalCollection._CachingChangeObserver({
callbacks: observeChangesCallbacks
});
// CachingChangeObserver clones all received input on its callbacks
// So we can mark it as safe to reduce the ejson clones.
// This is tested by the `mongo-livedata - (extended) scribbling` tests
changeObserver.applyChange._fromObserve = true;
const handle = cursor.observeChanges(changeObserver.applyChange,
{ nonMutatingCallbacks: true });
suppressed = false;
return handle;
};
LocalCollection._observeCallbacksAreOrdered = callbacks => {
if (callbacks.added && callbacks.addedAt) {
throw new Error('Please specify only one of added() and addedAt()');
}
if (callbacks.changed && callbacks.changedAt) {
throw new Error('Please specify only one of changed() and changedAt()');
}
if (callbacks.removed && callbacks.removedAt) {
throw new Error('Please specify only one of removed() and removedAt()');
}
return !!(
callbacks.addedAt ||
callbacks.changedAt ||
callbacks.movedTo ||
callbacks.removedAt
);
};
LocalCollection._observeChangesCallbacksAreOrdered = callbacks => {
if (callbacks.added && callbacks.addedBefore) {
throw new Error('Please specify only one of added() and addedBefore()');
}
return !!(callbacks.addedBefore || callbacks.movedBefore);
};
LocalCollection._removeFromResults = (query, doc) => {
if (query.ordered) {
const i = LocalCollection._findInOrderedResults(query, doc);
query.removed(doc._id);
query.results.splice(i, 1);
} else {
const id = doc._id; // in case callback mutates doc
query.removed(doc._id);
query.results.remove(id);
}
};
// Is this selector just shorthand for lookup by _id?
LocalCollection._selectorIsId = selector =>
typeof selector === 'number' ||
typeof selector === 'string' ||
selector instanceof MongoID.ObjectID
;
// Is the selector just lookup by _id (shorthand or not)?
LocalCollection._selectorIsIdPerhapsAsObject = selector =>
LocalCollection._selectorIsId(selector) ||
LocalCollection._selectorIsId(selector && selector._id) &&
Object.keys(selector).length === 1
;
LocalCollection._updateInResults = (query, doc, old_doc) => {
if (!EJSON.equals(doc._id, old_doc._id)) {
throw new Error('Can\'t change a doc\'s _id while updating');
}
const projectionFn = query.projectionFn;
const changedFields = DiffSequence.makeChangedFields(
projectionFn(doc),
projectionFn(old_doc)
);
if (!query.ordered) {
if (Object.keys(changedFields).length) {
query.changed(doc._id, changedFields);
query.results.set(doc._id, doc);
}
return;
}
const old_idx = LocalCollection._findInOrderedResults(query, doc);
if (Object.keys(changedFields).length) {
query.changed(doc._id, changedFields);
}
if (!query.sorter) {
return;
}
// just take it out and put it back in again, and see if the index changes
query.results.splice(old_idx, 1);
const new_idx = LocalCollection._insertInSortedList(
query.sorter.getComparator({distances: query.distances}),
query.results,
doc
);
if (old_idx !== new_idx) {
let next = query.results[new_idx + 1];
if (next) {
next = next._id;
} else {
next = null;
}
query.movedBefore && query.movedBefore(doc._id, next);
}
};
const MODIFIERS = {
$currentDate(target, field, arg) {
if (typeof arg === 'object' && hasOwn.call(arg, '$type')) {
if (arg.$type !== 'date') {
throw MinimongoError(
'Minimongo does currently only support the date type in ' +
'$currentDate modifiers',
{field}
);
}
} else if (arg !== true) {
throw MinimongoError('Invalid $currentDate modifier', {field});
}
target[field] = new Date();
},
$inc(target, field, arg) {
if (typeof arg !== 'number') {
throw MinimongoError('Modifier $inc allowed for numbers only', {field});
}
if (field in target) {
if (typeof target[field] !== 'number') {
throw MinimongoError(
'Cannot apply $inc modifier to non-number',
{field}
);
}
target[field] += arg;
} else {
target[field] = arg;
}
},
$min(target, field, arg) {
if (typeof arg !== 'number') {
throw MinimongoError('Modifier $min allowed for numbers only', {field});
}
if (field in target) {
if (typeof target[field] !== 'number') {
throw MinimongoError(
'Cannot apply $min modifier to non-number',
{field}
);
}
if (target[field] > arg) {
target[field] = arg;
}
} else {
target[field] = arg;
}
},
$max(target, field, arg) {
if (typeof arg !== 'number') {
throw MinimongoError('Modifier $max allowed for numbers only', {field});
}
if (field in target) {
if (typeof target[field] !== 'number') {
throw MinimongoError(
'Cannot apply $max modifier to non-number',
{field}
);
}
if (target[field] < arg) {
target[field] = arg;
}
} else {
target[field] = arg;
}
},
$mul(target, field, arg) {
if (typeof arg !== 'number') {
throw MinimongoError('Modifier $mul allowed for numbers only', {field});
}
if (field in target) {
if (typeof target[field] !== 'number') {
throw MinimongoError(
'Cannot apply $mul modifier to non-number',
{field}
);
}
target[field] *= arg;
} else {
target[field] = 0;
}
},
$rename(target, field, arg, keypath, doc) {
// no idea why mongo has this restriction..
if (keypath === arg) {
throw MinimongoError('$rename source must differ from target', {field});
}
if (target === null) {
throw MinimongoError('$rename source field invalid', {field});
}
if (typeof arg !== 'string') {
throw MinimongoError('$rename target must be a string', {field});
}
if (arg.includes('\0')) {
// Null bytes are not allowed in Mongo field names
// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names
throw MinimongoError(
'The \'to\' field for $rename cannot contain an embedded null byte',
{field}
);
}
if (target === undefined) {
return;
}
const object = target[field];
delete target[field];
const keyparts = arg.split('.');
const target2 = findModTarget(doc, keyparts, {forbidArray: true});
if (target2 === null) {
throw MinimongoError('$rename target field invalid', {field});
}
target2[keyparts.pop()] = object;
},
$set(target, field, arg) {
if (target !== Object(target)) { // not an array or an object
const error = MinimongoError(
'Cannot set property on non-object field',
{field}
);
error.setPropertyError = true;
throw error;
}
if (target === null) {
const error = MinimongoError('Cannot set property on null', {field});
error.setPropertyError = true;
throw error;
}
assertHasValidFieldNames(arg);
target[field] = arg;
},
$setOnInsert(target, field, arg) {
// converted to `$set` in `_modify`
},
$unset(target, field, arg) {
if (target !== undefined) {
if (target instanceof Array) {
if (field in target) {
target[field] = null;
}
} else {
delete target[field];
}
}
},
$push(target, field, arg) {
if (target[field] === undefined) {
target[field] = [];
}
if (!(target[field] instanceof Array)) {
throw MinimongoError('Cannot apply $push modifier to non-array', {field});
}
if (!(arg && arg.$each)) {
// Simple mode: not $each
assertHasValidFieldNames(arg);
target[field].push(arg);
return;
}
// Fancy mode: $each (and maybe $slice and $sort and $position)
const toPush = arg.$each;
if (!(toPush instanceof Array)) {
throw MinimongoError('$each must be an array', {field});
}
assertHasValidFieldNames(toPush);
// Parse $position
let position = undefined;
if ('$position' in arg) {
if (typeof arg.$position !== 'number') {
throw MinimongoError('$position must be a numeric value', {field});
}
// XXX should check to make sure integer
if (arg.$position < 0) {
throw MinimongoError(
'$position in $push must be zero or positive',
{field}
);
}
position = arg.$position;
}
// Parse $slice.
let slice = undefined;
if ('$slice' in arg) {
if (typeof arg.$slice !== 'number') {
throw MinimongoError('$slice must be a numeric value', {field});
}
// XXX should check to make sure integer
slice = arg.$slice;
}
// Parse $sort.
let sortFunction = undefined;
if (arg.$sort) {
if (slice === undefined) {
throw MinimongoError('$sort requires $slice to be present', {field});
}
// XXX this allows us to use a $sort whose value is an array, but that's
// actually an extension of the Node driver, so it won't work
// server-side. Could be confusing!
// XXX is it correct that we don't do geo-stuff here?
sortFunction = new Minimongo.Sorter(arg.$sort).getComparator();
toPush.forEach(element => {
if (LocalCollection._f._type(element) !== 3) {
throw MinimongoError(
'$push like modifiers using $sort require all elements to be ' +
'objects',
{field}
);
}
});
}
// Actually push.
if (position === undefined) {
toPush.forEach(element => {
target[field].push(element);
});
} else {
const spliceArguments = [position, 0];
toPush.forEach(element => {
spliceArguments.push(element);
});
target[field].splice(...spliceArguments);
}
// Actually sort.
if (sortFunction) {
target[field].sort(sortFunction);
}
// Actually slice.
if (slice !== undefined) {
if (slice === 0) {
target[field] = []; // differs from Array.slice!
} else if (slice < 0) {
target[field] = target[field].slice(slice);
} else {
target[field] = target[field].slice(0, slice);
}
}
},
$pushAll(target, field, arg) {
if (!(typeof arg === 'object' && arg instanceof Array)) {
throw MinimongoError('Modifier $pushAll/pullAll allowed for arrays only');
}
assertHasValidFieldNames(arg);
const toPush = target[field];
if (toPush === undefined) {
target[field] = arg;
} else if (!(toPush instanceof Array)) {
throw MinimongoError(
'Cannot apply $pushAll modifier to non-array',
{field}
);
} else {
toPush.push(...arg);
}
},
$addToSet(target, field, arg) {
let isEach = false;
if (typeof arg === 'object') {
// check if first key is '$each'
const keys = Object.keys(arg);
if (keys[0] === '$each') {
isEach = true;
}
}
const values = isEach ? arg.$each : [arg];
assertHasValidFieldNames(values);
const toAdd = target[field];
if (toAdd === undefined) {
target[field] = values;
} else if (!(toAdd instanceof Array)) {
throw MinimongoError(
'Cannot apply $addToSet modifier to non-array',
{field}
);
} else {
values.forEach(value => {
if (toAdd.some(element => LocalCollection._f._equal(value, element))) {
return;
}
toAdd.push(value);
});
}
},
$pop(target, field, arg) {
if (target === undefined) {
return;
}
const toPop = target[field];
if (toPop === undefined) {
return;
}
if (!(toPop instanceof Array)) {
throw MinimongoError('Cannot apply $pop modifier to non-array', {field});
}
if (typeof arg === 'number' && arg < 0) {
toPop.splice(0, 1);
} else {
toPop.pop();
}
},
$pull(target, field, arg) {
if (target === undefined) {
return;
}
const toPull = target[field];
if (toPull === undefined) {
return;
}
if (!(toPull instanceof Array)) {
throw MinimongoError(
'Cannot apply $pull/pullAll modifier to non-array',
{field}
);
}
let out;
if (arg != null && typeof arg === 'object' && !(arg instanceof Array)) {
// XXX would be much nicer to compile this once, rather than
// for each document we modify.. but usually we're not
// modifying that many documents, so we'll let it slide for
// now
// XXX Minimongo.Matcher isn't up for the job, because we need
// to permit stuff like {$pull: {a: {$gt: 4}}}.. something
// like {$gt: 4} is not normally a complete selector.
// same issue as $elemMatch possibly?
const matcher = new Minimongo.Matcher(arg);
out = toPull.filter(element => !matcher.documentMatches(element).result);
} else {
out = toPull.filter(element => !LocalCollection._f._equal(element, arg));
}
target[field] = out;
},
$pullAll(target, field, arg) {
if (!(typeof arg === 'object' && arg instanceof Array)) {
throw MinimongoError(
'Modifier $pushAll/pullAll allowed for arrays only',
{field}
);
}
if (target === undefined) {
return;
}
const toPull = target[field];
if (toPull === undefined) {
return;
}
if (!(toPull instanceof Array)) {
throw MinimongoError(
'Cannot apply $pull/pullAll modifier to non-array',
{field}
);
}
target[field] = toPull.filter(object =>
!arg.some(element => LocalCollection._f._equal(object, element))
);
},
$bit(target, field, arg) {
// XXX mongo only supports $bit on integers, and we only support
// native javascript numbers (doubles) so far, so we can't support $bit
throw MinimongoError('$bit is not supported', {field});
},
$v() {
// As discussed in https://github.com/meteor/meteor/issues/9623,
// the `$v` operator is not needed by Meteor, but problems can occur if
// it's not at least callable (as of Mongo >= 3.6). It's defined here as
// a no-op to work around these problems.
}
};
const NO_CREATE_MODIFIERS = {
$pop: true,
$pull: true,
$pullAll: true,
$rename: true,
$unset: true
};
// Make sure field names do not contain Mongo restricted
// characters ('.', '$', '\0').
// https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names
const invalidCharMsg = {
$: 'start with \'$\'',
'.': 'contain \'.\'',
'\0': 'contain null bytes'
};
// checks if all field names in an object are valid
function assertHasValidFieldNames(doc) {
if (doc && typeof doc === 'object') {
JSON.stringify(doc, (key, value) => {
assertIsValidFieldName(key);
return value;
});
}
}
function assertIsValidFieldName(key) {
let match;
if (typeof key === 'string' && (match = key.match(/^\$|\.|\0/))) {
throw MinimongoError(`Key ${key} must not ${invalidCharMsg[match[0]]}`);
}
}
// for a.b.c.2.d.e, keyparts should be ['a', 'b', 'c', '2', 'd', 'e'],
// and then you would operate on the 'e' property of the returned
// object.
//
// if options.noCreate is falsey, creates intermediate levels of
// structure as necessary, like mkdir -p (and raises an exception if
// that would mean giving a non-numeric property to an array.) if
// options.noCreate is true, return undefined instead.
//
// may modify the last element of keyparts to signal to the caller that it needs
// to use a different value to index into the returned object (for example,
// ['a', '01'] -> ['a', 1]).
//
// if forbidArray is true, return null if the keypath goes through an array.
//
// if options.arrayIndices is set, use its first element for the (first) '$' in
// the path.
function findModTarget(doc, keyparts, options = {}) {
let usedArrayIndex = false;
for (let i = 0; i < keyparts.length; i++) {
const last = i === keyparts.length - 1;
let keypart = keyparts[i];
if (!isIndexable(doc)) {
if (options.noCreate) {
return undefined;
}
const error = MinimongoError(
`cannot use the part '${keypart}' to traverse ${doc}`
);
error.setPropertyError = true;
throw error;
}
if (doc instanceof Array) {
if (options.forbidArray) {
return null;
}
if (keypart === '$') {
if (usedArrayIndex) {
throw MinimongoError('Too many positional (i.e. \'$\') elements');
}
if (!options.arrayIndices || !options.arrayIndices.length) {
throw MinimongoError(
'The positional operator did not find the match needed from the ' +
'query'
);
}
keypart = options.arrayIndices[0];
usedArrayIndex = true;
} else if (isNumericKey(keypart)) {
keypart = parseInt(keypart);
} else {
if (options.noCreate) {
return undefined;
}
throw MinimongoError(
`can't append to array using string field name [${keypart}]`
);
}
if (last) {
keyparts[i] = keypart; // handle 'a.01'
}
if (options.noCreate && keypart >= doc.length) {
return undefined;
}
while (doc.length < keypart) {
doc.push(null);
}
if (!last) {
if (doc.length === keypart) {
doc.push({});
} else if (typeof doc[keypart] !== 'object') {
throw MinimongoError(
`can't modify field '${keyparts[i + 1]}' of list value ` +
JSON.stringify(doc[keypart])
);
}
}
} else {
assertIsValidFieldName(keypart);
if (!(keypart in doc)) {
if (options.noCreate) {
return undefined;
}
if (!last) {
doc[keypart] = {};
}
}
}
if (last) {
return doc;
}
doc = doc[keypart];
}
// notreached
}