mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Mongo 2.4 had the following behavior: if
c.update({_id: 'x'}, {y: 1}, {upsert: 1})
resulted in an insert, it would get a random _id instead of 'x'. (This
only happened when the update was a "replacement" rather than a
"modifier".) We dutifully implemented this behavior in various parts of
Meteor and added tests to prove that it occured.
However, this was actually a bug which got fixed in Mongo 2.6:
https://jira.mongodb.org/browse/SERVER-5289
Since this was regarded by Mongo as a bug (not a backwards-incompatible
change deemed worthy of mention in the Mongo 2.6 release notes), we
should just make Meteor always behave the Mongo 2.6 way, no matter what
version we're on (at least in the aspects where Meteor has its own code
to control this). In other parts of Meteor where the queries are just
getting processed by Mongo, the behavior of this special case will match
Mongo's behavior. If you care strongly about consistent behavior in
this strange edge case, upgrade to Mongo 2.6!
Specifically:
- The minimongo implementation of modifiers loses its special-case code
which drops _id from the modified document during an upsert replace.
- Mongo.Collection.update should only generate a random _id as an
insertedId if the selector doesn't mention one.
- The case of a selector mentioning _id and a *replacement* modifier
not mentioning _id should still count as "known ID" in the
lower-level MongoConnection.update, which means that there's no
reason to use simulated upsert and that we should include it as
insertedId in the return object.
- Various tests of the previous behavior should be changed.
(Note that if this commit is cherry-picked onto 1.0.3.1 (ie, run against
2.4), it would fail, because some of the tested cases end up going to
unsimulated Mongo upsert and assume the 2.6 behavior. That's OK, as
described above.)
Fixes #2278.
1114 lines
42 KiB
JavaScript
1114 lines
42 KiB
JavaScript
// options.connection, if given, is a LivedataClient or LivedataServer
|
|
// XXX presently there is no way to destroy/clean up a Collection
|
|
|
|
/**
|
|
* @summary Namespace for MongoDB-related items
|
|
* @namespace
|
|
*/
|
|
Mongo = {};
|
|
|
|
/**
|
|
* @summary Constructor for a Collection
|
|
* @locus Anywhere
|
|
* @instancename collection
|
|
* @class
|
|
* @param {String} name The name of the collection. If null, creates an unmanaged (unsynchronized) local collection.
|
|
* @param {Object} [options]
|
|
* @param {Object} options.connection The server connection that will manage this collection. Uses the default connection if not specified. Pass the return value of calling [`DDP.connect`](#ddp_connect) to specify a different server. Pass `null` to specify no connection. Unmanaged (`name` is null) collections cannot specify a connection.
|
|
* @param {String} options.idGeneration The method of generating the `_id` fields of new documents in this collection. Possible values:
|
|
|
|
- **`'STRING'`**: random strings
|
|
- **`'MONGO'`**: random [`Mongo.ObjectID`](#mongo_object_id) values
|
|
|
|
The default id generation technique is `'STRING'`.
|
|
* @param {Function} options.transform An optional transformation function. Documents will be passed through this function before being returned from `fetch` or `findOne`, and before being passed to callbacks of `observe`, `map`, `forEach`, `allow`, and `deny`. Transforms are *not* applied for the callbacks of `observeChanges` or to cursors returned from publish functions.
|
|
*/
|
|
Mongo.Collection = function (name, options) {
|
|
var self = this;
|
|
if (! (self instanceof Mongo.Collection))
|
|
throw new Error('use "new" to construct a Mongo.Collection');
|
|
|
|
if (!name && (name !== null)) {
|
|
Meteor._debug("Warning: creating anonymous collection. It will not be " +
|
|
"saved or synchronized over the network. (Pass null for " +
|
|
"the collection name to turn off this warning.)");
|
|
name = null;
|
|
}
|
|
|
|
if (name !== null && typeof name !== "string") {
|
|
throw new Error(
|
|
"First argument to new Mongo.Collection must be a string or null");
|
|
}
|
|
|
|
if (options && options.methods) {
|
|
// Backwards compatibility hack with original signature (which passed
|
|
// "connection" directly instead of in options. (Connections must have a "methods"
|
|
// method.)
|
|
// XXX remove before 1.0
|
|
options = {connection: options};
|
|
}
|
|
// Backwards compatibility: "connection" used to be called "manager".
|
|
if (options && options.manager && !options.connection) {
|
|
options.connection = options.manager;
|
|
}
|
|
options = _.extend({
|
|
connection: undefined,
|
|
idGeneration: 'STRING',
|
|
transform: null,
|
|
_driver: undefined,
|
|
_preventAutopublish: false
|
|
}, options);
|
|
|
|
switch (options.idGeneration) {
|
|
case 'MONGO':
|
|
self._makeNewID = function () {
|
|
var src = name ? DDP.randomStream('/collection/' + name) : Random;
|
|
return new Mongo.ObjectID(src.hexString(24));
|
|
};
|
|
break;
|
|
case 'STRING':
|
|
default:
|
|
self._makeNewID = function () {
|
|
var src = name ? DDP.randomStream('/collection/' + name) : Random;
|
|
return src.id();
|
|
};
|
|
break;
|
|
}
|
|
|
|
self._transform = LocalCollection.wrapTransform(options.transform);
|
|
|
|
if (! name || options.connection === null)
|
|
// note: nameless collections never have a connection
|
|
self._connection = null;
|
|
else if (options.connection)
|
|
self._connection = options.connection;
|
|
else if (Meteor.isClient)
|
|
self._connection = Meteor.connection;
|
|
else
|
|
self._connection = Meteor.server;
|
|
|
|
if (!options._driver) {
|
|
// XXX This check assumes that webapp is loaded so that Meteor.server !==
|
|
// null. We should fully support the case of "want to use a Mongo-backed
|
|
// collection from Node code without webapp", but we don't yet.
|
|
// #MeteorServerNull
|
|
if (name && self._connection === Meteor.server &&
|
|
typeof MongoInternals !== "undefined" &&
|
|
MongoInternals.defaultRemoteCollectionDriver) {
|
|
options._driver = MongoInternals.defaultRemoteCollectionDriver();
|
|
} else {
|
|
options._driver = LocalCollectionDriver;
|
|
}
|
|
}
|
|
|
|
self._collection = options._driver.open(name, self._connection);
|
|
self._name = name;
|
|
|
|
if (self._connection && self._connection.registerStore) {
|
|
// OK, we're going to be a slave, replicating some remote
|
|
// database, except possibly with some temporary divergence while
|
|
// we have unacknowledged RPC's.
|
|
var ok = self._connection.registerStore(name, {
|
|
// Called at the beginning of a batch of updates. batchSize is the number
|
|
// of update calls to expect.
|
|
//
|
|
// XXX This interface is pretty janky. reset probably ought to go back to
|
|
// being its own function, and callers shouldn't have to calculate
|
|
// batchSize. The optimization of not calling pause/remove should be
|
|
// delayed until later: the first call to update() should buffer its
|
|
// message, and then we can either directly apply it at endUpdate time if
|
|
// it was the only update, or do pauseObservers/apply/apply at the next
|
|
// update() if there's another one.
|
|
beginUpdate: function (batchSize, reset) {
|
|
// pause observers so users don't see flicker when updating several
|
|
// objects at once (including the post-reconnect reset-and-reapply
|
|
// stage), and so that a re-sorting of a query can take advantage of the
|
|
// full _diffQuery moved calculation instead of applying change one at a
|
|
// time.
|
|
if (batchSize > 1 || reset)
|
|
self._collection.pauseObservers();
|
|
|
|
if (reset)
|
|
self._collection.remove({});
|
|
},
|
|
|
|
// Apply an update.
|
|
// XXX better specify this interface (not in terms of a wire message)?
|
|
update: function (msg) {
|
|
var mongoId = LocalCollection._idParse(msg.id);
|
|
var doc = self._collection.findOne(mongoId);
|
|
|
|
// Is this a "replace the whole doc" message coming from the quiescence
|
|
// of method writes to an object? (Note that 'undefined' is a valid
|
|
// value meaning "remove it".)
|
|
if (msg.msg === 'replace') {
|
|
var replace = msg.replace;
|
|
if (!replace) {
|
|
if (doc)
|
|
self._collection.remove(mongoId);
|
|
} else if (!doc) {
|
|
self._collection.insert(replace);
|
|
} else {
|
|
// XXX check that replace has no $ ops
|
|
self._collection.update(mongoId, replace);
|
|
}
|
|
return;
|
|
} else if (msg.msg === 'added') {
|
|
if (doc) {
|
|
throw new Error("Expected not to find a document already present for an add");
|
|
}
|
|
self._collection.insert(_.extend({_id: mongoId}, msg.fields));
|
|
} else if (msg.msg === 'removed') {
|
|
if (!doc)
|
|
throw new Error("Expected to find a document already present for removed");
|
|
self._collection.remove(mongoId);
|
|
} else if (msg.msg === 'changed') {
|
|
if (!doc)
|
|
throw new Error("Expected to find a document to change");
|
|
if (!_.isEmpty(msg.fields)) {
|
|
var modifier = {};
|
|
_.each(msg.fields, function (value, key) {
|
|
if (value === undefined) {
|
|
if (!modifier.$unset)
|
|
modifier.$unset = {};
|
|
modifier.$unset[key] = 1;
|
|
} else {
|
|
if (!modifier.$set)
|
|
modifier.$set = {};
|
|
modifier.$set[key] = value;
|
|
}
|
|
});
|
|
self._collection.update(mongoId, modifier);
|
|
}
|
|
} else {
|
|
throw new Error("I don't know how to deal with this message");
|
|
}
|
|
|
|
},
|
|
|
|
// Called at the end of a batch of updates.
|
|
endUpdate: function () {
|
|
self._collection.resumeObservers();
|
|
},
|
|
|
|
// Called around method stub invocations to capture the original versions
|
|
// of modified documents.
|
|
saveOriginals: function () {
|
|
self._collection.saveOriginals();
|
|
},
|
|
retrieveOriginals: function () {
|
|
return self._collection.retrieveOriginals();
|
|
}
|
|
});
|
|
|
|
if (!ok)
|
|
throw new Error("There is already a collection named '" + name + "'");
|
|
}
|
|
|
|
self._defineMutationMethods();
|
|
|
|
// autopublish
|
|
if (Package.autopublish && !options._preventAutopublish && self._connection
|
|
&& self._connection.publish) {
|
|
self._connection.publish(null, function () {
|
|
return self.find();
|
|
}, {is_auto: true});
|
|
}
|
|
};
|
|
|
|
///
|
|
/// Main collection API
|
|
///
|
|
|
|
|
|
_.extend(Mongo.Collection.prototype, {
|
|
|
|
_getFindSelector: function (args) {
|
|
if (args.length == 0)
|
|
return {};
|
|
else
|
|
return args[0];
|
|
},
|
|
|
|
_getFindOptions: function (args) {
|
|
var self = this;
|
|
if (args.length < 2) {
|
|
return { transform: self._transform };
|
|
} else {
|
|
check(args[1], Match.Optional(Match.ObjectIncluding({
|
|
fields: Match.Optional(Match.OneOf(Object, undefined)),
|
|
sort: Match.Optional(Match.OneOf(Object, Array, undefined)),
|
|
limit: Match.Optional(Match.OneOf(Number, undefined)),
|
|
skip: Match.Optional(Match.OneOf(Number, undefined))
|
|
})));
|
|
|
|
return _.extend({
|
|
transform: self._transform
|
|
}, args[1]);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @summary Find the documents in a collection that match the selector.
|
|
* @locus Anywhere
|
|
* @method find
|
|
* @memberOf Mongo.Collection
|
|
* @instance
|
|
* @param {MongoSelector} [selector] A query describing the documents to find
|
|
* @param {Object} [options]
|
|
* @param {MongoSortSpecifier} options.sort Sort order (default: natural order)
|
|
* @param {Number} options.skip Number of results to skip at the beginning
|
|
* @param {Number} options.limit Maximum number of results to return
|
|
* @param {MongoFieldSpecifier} options.fields Dictionary of fields to return or exclude.
|
|
* @param {Boolean} options.reactive (Client only) Default `true`; pass `false` to disable reactivity
|
|
* @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections) for this cursor. Pass `null` to disable transformation.
|
|
* @returns {Mongo.Cursor}
|
|
*/
|
|
find: function (/* selector, options */) {
|
|
// Collection.find() (return all docs) behaves differently
|
|
// from Collection.find(undefined) (return 0 docs). so be
|
|
// careful about the length of arguments.
|
|
var self = this;
|
|
var argArray = _.toArray(arguments);
|
|
return self._collection.find(self._getFindSelector(argArray),
|
|
self._getFindOptions(argArray));
|
|
},
|
|
|
|
/**
|
|
* @summary Finds the first document that matches the selector, as ordered by sort and skip options.
|
|
* @locus Anywhere
|
|
* @method findOne
|
|
* @memberOf Mongo.Collection
|
|
* @instance
|
|
* @param {MongoSelector} [selector] A query describing the documents to find
|
|
* @param {Object} [options]
|
|
* @param {MongoSortSpecifier} options.sort Sort order (default: natural order)
|
|
* @param {Number} options.skip Number of results to skip at the beginning
|
|
* @param {MongoFieldSpecifier} options.fields Dictionary of fields to return or exclude.
|
|
* @param {Boolean} options.reactive (Client only) Default true; pass false to disable reactivity
|
|
* @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections) for this cursor. Pass `null` to disable transformation.
|
|
* @returns {Object}
|
|
*/
|
|
findOne: function (/* selector, options */) {
|
|
var self = this;
|
|
var argArray = _.toArray(arguments);
|
|
return self._collection.findOne(self._getFindSelector(argArray),
|
|
self._getFindOptions(argArray));
|
|
}
|
|
|
|
});
|
|
|
|
Mongo.Collection._publishCursor = function (cursor, sub, collection) {
|
|
var observeHandle = cursor.observeChanges({
|
|
added: function (id, fields) {
|
|
sub.added(collection, id, fields);
|
|
},
|
|
changed: function (id, fields) {
|
|
sub.changed(collection, id, fields);
|
|
},
|
|
removed: function (id) {
|
|
sub.removed(collection, id);
|
|
}
|
|
});
|
|
|
|
// We don't call sub.ready() here: it gets called in livedata_server, after
|
|
// possibly calling _publishCursor on multiple returned cursors.
|
|
|
|
// register stop callback (expects lambda w/ no args).
|
|
sub.onStop(function () {observeHandle.stop();});
|
|
};
|
|
|
|
// protect against dangerous selectors. falsey and {_id: falsey} are both
|
|
// likely programmer error, and not what you want, particularly for destructive
|
|
// operations. JS regexps don't serialize over DDP but can be trivially
|
|
// replaced by $regex.
|
|
Mongo.Collection._rewriteSelector = function (selector) {
|
|
// shorthand -- scalars match _id
|
|
if (LocalCollection._selectorIsId(selector))
|
|
selector = {_id: selector};
|
|
|
|
if (!selector || (('_id' in selector) && !selector._id))
|
|
// can't match anything
|
|
return {_id: Random.id()};
|
|
|
|
var ret = {};
|
|
_.each(selector, function (value, key) {
|
|
// Mongo supports both {field: /foo/} and {field: {$regex: /foo/}}
|
|
if (value instanceof RegExp) {
|
|
ret[key] = convertRegexpToMongoSelector(value);
|
|
} else if (value && value.$regex instanceof RegExp) {
|
|
ret[key] = convertRegexpToMongoSelector(value.$regex);
|
|
// if value is {$regex: /foo/, $options: ...} then $options
|
|
// override the ones set on $regex.
|
|
if (value.$options !== undefined)
|
|
ret[key].$options = value.$options;
|
|
}
|
|
else if (_.contains(['$or','$and','$nor'], key)) {
|
|
// Translate lower levels of $and/$or/$nor
|
|
ret[key] = _.map(value, function (v) {
|
|
return Mongo.Collection._rewriteSelector(v);
|
|
});
|
|
} else {
|
|
ret[key] = value;
|
|
}
|
|
});
|
|
return ret;
|
|
};
|
|
|
|
// convert a JS RegExp object to a Mongo {$regex: ..., $options: ...}
|
|
// selector
|
|
var convertRegexpToMongoSelector = function (regexp) {
|
|
check(regexp, RegExp); // safety belt
|
|
|
|
var selector = {$regex: regexp.source};
|
|
var regexOptions = '';
|
|
// JS RegExp objects support 'i', 'm', and 'g'. Mongo regex $options
|
|
// support 'i', 'm', 'x', and 's'. So we support 'i' and 'm' here.
|
|
if (regexp.ignoreCase)
|
|
regexOptions += 'i';
|
|
if (regexp.multiline)
|
|
regexOptions += 'm';
|
|
if (regexOptions)
|
|
selector.$options = regexOptions;
|
|
|
|
return selector;
|
|
};
|
|
|
|
var throwIfSelectorIsNotId = function (selector, methodName) {
|
|
if (!LocalCollection._selectorIsIdPerhapsAsObject(selector)) {
|
|
throw new Meteor.Error(
|
|
403, "Not permitted. Untrusted code may only " + methodName +
|
|
" documents by ID.");
|
|
}
|
|
};
|
|
|
|
// 'insert' immediately returns the inserted document's new _id.
|
|
// The others return values immediately if you are in a stub, an in-memory
|
|
// unmanaged collection, or a mongo-backed collection and you don't pass a
|
|
// callback. 'update' and 'remove' return the number of affected
|
|
// documents. 'upsert' returns an object with keys 'numberAffected' and, if an
|
|
// insert happened, 'insertedId'.
|
|
//
|
|
// Otherwise, the semantics are exactly like other methods: they take
|
|
// a callback as an optional last argument; if no callback is
|
|
// provided, they block until the operation is complete, and throw an
|
|
// exception if it fails; if a callback is provided, then they don't
|
|
// necessarily block, and they call the callback when they finish with error and
|
|
// result arguments. (The insert method provides the document ID as its result;
|
|
// update and remove provide the number of affected docs as the result; upsert
|
|
// provides an object with numberAffected and maybe insertedId.)
|
|
//
|
|
// On the client, blocking is impossible, so if a callback
|
|
// isn't provided, they just return immediately and any error
|
|
// information is lost.
|
|
//
|
|
// There's one more tweak. On the client, if you don't provide a
|
|
// callback, then if there is an error, a message will be logged with
|
|
// Meteor._debug.
|
|
//
|
|
// The intent (though this is actually determined by the underlying
|
|
// drivers) is that the operations should be done synchronously, not
|
|
// generating their result until the database has acknowledged
|
|
// them. In the future maybe we should provide a flag to turn this
|
|
// off.
|
|
|
|
/**
|
|
* @summary Insert a document in the collection. Returns its unique _id.
|
|
* @locus Anywhere
|
|
* @method insert
|
|
* @memberOf Mongo.Collection
|
|
* @instance
|
|
* @param {Object} doc The document to insert. May not yet have an _id attribute, in which case Meteor will generate one for you.
|
|
* @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the _id as the second.
|
|
*/
|
|
|
|
/**
|
|
* @summary Modify one or more documents in the collection. Returns the number of affected documents.
|
|
* @locus Anywhere
|
|
* @method update
|
|
* @memberOf Mongo.Collection
|
|
* @instance
|
|
* @param {MongoSelector} selector Specifies which documents to modify
|
|
* @param {MongoModifier} modifier Specifies how to modify the documents
|
|
* @param {Object} [options]
|
|
* @param {Boolean} options.multi True to modify all matching documents; false to only modify one of the matching documents (the default).
|
|
* @param {Boolean} options.upsert True to insert a document if no matching documents are found.
|
|
* @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the number of affected documents as the second.
|
|
*/
|
|
|
|
/**
|
|
* @summary Remove documents from the collection
|
|
* @locus Anywhere
|
|
* @method remove
|
|
* @memberOf Mongo.Collection
|
|
* @instance
|
|
* @param {MongoSelector} selector Specifies which documents to remove
|
|
* @param {Function} [callback] Optional. If present, called with an error object as its argument.
|
|
*/
|
|
|
|
_.each(["insert", "update", "remove"], function (name) {
|
|
Mongo.Collection.prototype[name] = function (/* arguments */) {
|
|
var self = this;
|
|
var args = _.toArray(arguments);
|
|
var callback;
|
|
var insertId;
|
|
var ret;
|
|
|
|
// Pull off any callback (or perhaps a 'callback' variable that was passed
|
|
// in undefined, like how 'upsert' does it).
|
|
if (args.length &&
|
|
(args[args.length - 1] === undefined ||
|
|
args[args.length - 1] instanceof Function)) {
|
|
callback = args.pop();
|
|
}
|
|
|
|
if (name === "insert") {
|
|
if (!args.length)
|
|
throw new Error("insert requires an argument");
|
|
// shallow-copy the document and generate an ID
|
|
args[0] = _.extend({}, args[0]);
|
|
if ('_id' in args[0]) {
|
|
insertId = args[0]._id;
|
|
if (!insertId || !(typeof insertId === 'string'
|
|
|| insertId instanceof Mongo.ObjectID))
|
|
throw new Error("Meteor requires document _id fields to be non-empty strings or ObjectIDs");
|
|
} else {
|
|
var generateId = true;
|
|
// Don't generate the id if we're the client and the 'outermost' call
|
|
// This optimization saves us passing both the randomSeed and the id
|
|
// Passing both is redundant.
|
|
if (self._connection && self._connection !== Meteor.server) {
|
|
var enclosing = DDP._CurrentInvocation.get();
|
|
if (!enclosing) {
|
|
generateId = false;
|
|
}
|
|
}
|
|
if (generateId) {
|
|
insertId = args[0]._id = self._makeNewID();
|
|
}
|
|
}
|
|
} else {
|
|
args[0] = Mongo.Collection._rewriteSelector(args[0]);
|
|
|
|
if (name === "update") {
|
|
// Mutate args but copy the original options object. We need to add
|
|
// insertedId to options, but don't want to mutate the caller's options
|
|
// object. We need to mutate `args` because we pass `args` into the
|
|
// driver below.
|
|
var options = args[2] = _.clone(args[2]) || {};
|
|
if (options && typeof options !== "function" && options.upsert) {
|
|
// set `insertedId` if absent. `insertedId` is a Meteor extension.
|
|
if (options.insertedId) {
|
|
if (!(typeof options.insertedId === 'string'
|
|
|| options.insertedId instanceof Mongo.ObjectID))
|
|
throw new Error("insertedId must be string or ObjectID");
|
|
} else if (! args[0]._id) {
|
|
options.insertedId = self._makeNewID();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// On inserts, always return the id that we generated; on all other
|
|
// operations, just return the result from the collection.
|
|
var chooseReturnValueFromCollectionResult = function (result) {
|
|
if (name === "insert") {
|
|
if (!insertId && result) {
|
|
insertId = result;
|
|
}
|
|
return insertId;
|
|
} else {
|
|
return result;
|
|
}
|
|
};
|
|
|
|
var wrappedCallback;
|
|
if (callback) {
|
|
wrappedCallback = function (error, result) {
|
|
callback(error, ! error && chooseReturnValueFromCollectionResult(result));
|
|
};
|
|
}
|
|
|
|
// XXX see #MeteorServerNull
|
|
if (self._connection && self._connection !== Meteor.server) {
|
|
// just remote to another endpoint, propagate return value or
|
|
// exception.
|
|
|
|
var enclosing = DDP._CurrentInvocation.get();
|
|
var alreadyInSimulation = enclosing && enclosing.isSimulation;
|
|
|
|
if (Meteor.isClient && !wrappedCallback && ! alreadyInSimulation) {
|
|
// Client can't block, so it can't report errors by exception,
|
|
// only by callback. If they forget the callback, give them a
|
|
// default one that logs the error, so they aren't totally
|
|
// baffled if their writes don't work because their database is
|
|
// down.
|
|
// Don't give a default callback in simulation, because inside stubs we
|
|
// want to return the results from the local collection immediately and
|
|
// not force a callback.
|
|
wrappedCallback = function (err) {
|
|
if (err)
|
|
Meteor._debug(name + " failed: " + (err.reason || err.stack));
|
|
};
|
|
}
|
|
|
|
if (!alreadyInSimulation && name !== "insert") {
|
|
// If we're about to actually send an RPC, we should throw an error if
|
|
// this is a non-ID selector, because the mutation methods only allow
|
|
// single-ID selectors. (If we don't throw here, we'll see flicker.)
|
|
throwIfSelectorIsNotId(args[0], name);
|
|
}
|
|
|
|
ret = chooseReturnValueFromCollectionResult(
|
|
self._connection.apply(self._prefix + name, args, {returnStubValue: true}, wrappedCallback)
|
|
);
|
|
|
|
} else {
|
|
// it's my collection. descend into the collection object
|
|
// and propagate any exception.
|
|
args.push(wrappedCallback);
|
|
try {
|
|
// If the user provided a callback and the collection implements this
|
|
// operation asynchronously, then queryRet will be undefined, and the
|
|
// result will be returned through the callback instead.
|
|
var queryRet = self._collection[name].apply(self._collection, args);
|
|
ret = chooseReturnValueFromCollectionResult(queryRet);
|
|
} catch (e) {
|
|
if (callback) {
|
|
callback(e);
|
|
return null;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// both sync and async, unless we threw an exception, return ret
|
|
// (new document ID for insert, num affected for update/remove, object with
|
|
// numberAffected and maybe insertedId for upsert).
|
|
return ret;
|
|
};
|
|
});
|
|
|
|
/**
|
|
* @summary Modify one or more documents in the collection, or insert one if no matching documents were found. Returns an object with keys `numberAffected` (the number of documents modified) and `insertedId` (the unique _id of the document that was inserted, if any).
|
|
* @locus Anywhere
|
|
* @param {MongoSelector} selector Specifies which documents to modify
|
|
* @param {MongoModifier} modifier Specifies how to modify the documents
|
|
* @param {Object} [options]
|
|
* @param {Boolean} options.multi True to modify all matching documents; false to only modify one of the matching documents (the default).
|
|
* @param {Function} [callback] Optional. If present, called with an error object as the first argument and, if no error, the number of affected documents as the second.
|
|
*/
|
|
Mongo.Collection.prototype.upsert = function (selector, modifier,
|
|
options, callback) {
|
|
var self = this;
|
|
if (! callback && typeof options === "function") {
|
|
callback = options;
|
|
options = {};
|
|
}
|
|
return self.update(selector, modifier,
|
|
_.extend({}, options, { _returnObject: true, upsert: true }),
|
|
callback);
|
|
};
|
|
|
|
// We'll actually design an index API later. For now, we just pass through to
|
|
// Mongo's, but make it synchronous.
|
|
Mongo.Collection.prototype._ensureIndex = function (index, options) {
|
|
var self = this;
|
|
if (!self._collection._ensureIndex)
|
|
throw new Error("Can only call _ensureIndex on server collections");
|
|
self._collection._ensureIndex(index, options);
|
|
};
|
|
Mongo.Collection.prototype._dropIndex = function (index) {
|
|
var self = this;
|
|
if (!self._collection._dropIndex)
|
|
throw new Error("Can only call _dropIndex on server collections");
|
|
self._collection._dropIndex(index);
|
|
};
|
|
Mongo.Collection.prototype._dropCollection = function () {
|
|
var self = this;
|
|
if (!self._collection.dropCollection)
|
|
throw new Error("Can only call _dropCollection on server collections");
|
|
self._collection.dropCollection();
|
|
};
|
|
Mongo.Collection.prototype._createCappedCollection = function (byteSize, maxDocuments) {
|
|
var self = this;
|
|
if (!self._collection._createCappedCollection)
|
|
throw new Error("Can only call _createCappedCollection on server collections");
|
|
self._collection._createCappedCollection(byteSize, maxDocuments);
|
|
};
|
|
|
|
/**
|
|
* @summary Create a Mongo-style `ObjectID`. If you don't specify a `hexString`, the `ObjectID` will generated randomly (not using MongoDB's ID construction rules).
|
|
* @locus Anywhere
|
|
* @class
|
|
* @param {String} hexString Optional. The 24-character hexadecimal contents of the ObjectID to create
|
|
*/
|
|
Mongo.ObjectID = LocalCollection._ObjectID;
|
|
|
|
/**
|
|
* @summary To create a cursor, use find. To access the documents in a cursor, use forEach, map, or fetch.
|
|
* @class
|
|
* @instanceName cursor
|
|
*/
|
|
Mongo.Cursor = LocalCollection.Cursor;
|
|
|
|
/**
|
|
* @deprecated in 0.9.1
|
|
*/
|
|
Mongo.Collection.Cursor = Mongo.Cursor;
|
|
|
|
/**
|
|
* @deprecated in 0.9.1
|
|
*/
|
|
Mongo.Collection.ObjectID = Mongo.ObjectID;
|
|
|
|
///
|
|
/// Remote methods and access control.
|
|
///
|
|
|
|
// Restrict default mutators on collection. allow() and deny() take the
|
|
// same options:
|
|
//
|
|
// options.insert {Function(userId, doc)}
|
|
// return true to allow/deny adding this document
|
|
//
|
|
// options.update {Function(userId, docs, fields, modifier)}
|
|
// return true to allow/deny updating these documents.
|
|
// `fields` is passed as an array of fields that are to be modified
|
|
//
|
|
// options.remove {Function(userId, docs)}
|
|
// return true to allow/deny removing these documents
|
|
//
|
|
// options.fetch {Array}
|
|
// Fields to fetch for these validators. If any call to allow or deny
|
|
// does not have this option then all fields are loaded.
|
|
//
|
|
// allow and deny can be called multiple times. The validators are
|
|
// evaluated as follows:
|
|
// - If neither deny() nor allow() has been called on the collection,
|
|
// then the request is allowed if and only if the "insecure" smart
|
|
// package is in use.
|
|
// - Otherwise, if any deny() function returns true, the request is denied.
|
|
// - Otherwise, if any allow() function returns true, the request is allowed.
|
|
// - Otherwise, the request is denied.
|
|
//
|
|
// Meteor may call your deny() and allow() functions in any order, and may not
|
|
// call all of them if it is able to make a decision without calling them all
|
|
// (so don't include side effects).
|
|
|
|
(function () {
|
|
var addValidator = function(allowOrDeny, options) {
|
|
// validate keys
|
|
var VALID_KEYS = ['insert', 'update', 'remove', 'fetch', 'transform'];
|
|
_.each(_.keys(options), function (key) {
|
|
if (!_.contains(VALID_KEYS, key))
|
|
throw new Error(allowOrDeny + ": Invalid key: " + key);
|
|
});
|
|
|
|
var self = this;
|
|
self._restricted = true;
|
|
|
|
_.each(['insert', 'update', 'remove'], function (name) {
|
|
if (options[name]) {
|
|
if (!(options[name] instanceof Function)) {
|
|
throw new Error(allowOrDeny + ": Value for `" + name + "` must be a function");
|
|
}
|
|
|
|
// If the transform is specified at all (including as 'null') in this
|
|
// call, then take that; otherwise, take the transform from the
|
|
// collection.
|
|
if (options.transform === undefined) {
|
|
options[name].transform = self._transform; // already wrapped
|
|
} else {
|
|
options[name].transform = LocalCollection.wrapTransform(
|
|
options.transform);
|
|
}
|
|
|
|
self._validators[name][allowOrDeny].push(options[name]);
|
|
}
|
|
});
|
|
|
|
// Only update the fetch fields if we're passed things that affect
|
|
// fetching. This way allow({}) and allow({insert: f}) don't result in
|
|
// setting fetchAllFields
|
|
if (options.update || options.remove || options.fetch) {
|
|
if (options.fetch && !(options.fetch instanceof Array)) {
|
|
throw new Error(allowOrDeny + ": Value for `fetch` must be an array");
|
|
}
|
|
self._updateFetch(options.fetch);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @summary Allow users to write directly to this collection from client code, subject to limitations you define.
|
|
* @locus Server
|
|
* @param {Object} options
|
|
* @param {Function} options.insert,update,remove Functions that look at a proposed modification to the database and return true if it should be allowed.
|
|
* @param {String[]} options.fetch Optional performance enhancement. Limits the fields that will be fetched from the database for inspection by your `update` and `remove` functions.
|
|
* @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections). Pass `null` to disable transformation.
|
|
*/
|
|
Mongo.Collection.prototype.allow = function(options) {
|
|
addValidator.call(this, 'allow', options);
|
|
};
|
|
|
|
/**
|
|
* @summary Override `allow` rules.
|
|
* @locus Server
|
|
* @param {Object} options
|
|
* @param {Function} options.insert,update,remove Functions that look at a proposed modification to the database and return true if it should be denied, even if an [allow](#allow) rule says otherwise.
|
|
* @param {String[]} options.fetch Optional performance enhancement. Limits the fields that will be fetched from the database for inspection by your `update` and `remove` functions.
|
|
* @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections). Pass `null` to disable transformation.
|
|
*/
|
|
Mongo.Collection.prototype.deny = function(options) {
|
|
addValidator.call(this, 'deny', options);
|
|
};
|
|
})();
|
|
|
|
|
|
Mongo.Collection.prototype._defineMutationMethods = function() {
|
|
var self = this;
|
|
|
|
// set to true once we call any allow or deny methods. If true, use
|
|
// allow/deny semantics. If false, use insecure mode semantics.
|
|
self._restricted = false;
|
|
|
|
// Insecure mode (default to allowing writes). Defaults to 'undefined' which
|
|
// means insecure iff the insecure package is loaded. This property can be
|
|
// overriden by tests or packages wishing to change insecure mode behavior of
|
|
// their collections.
|
|
self._insecure = undefined;
|
|
|
|
self._validators = {
|
|
insert: {allow: [], deny: []},
|
|
update: {allow: [], deny: []},
|
|
remove: {allow: [], deny: []},
|
|
upsert: {allow: [], deny: []}, // dummy arrays; can't set these!
|
|
fetch: [],
|
|
fetchAllFields: false
|
|
};
|
|
|
|
if (!self._name)
|
|
return; // anonymous collection
|
|
|
|
// XXX Think about method namespacing. Maybe methods should be
|
|
// "Meteor:Mongo:insert/NAME"?
|
|
self._prefix = '/' + self._name + '/';
|
|
|
|
// mutation methods
|
|
if (self._connection) {
|
|
var m = {};
|
|
|
|
_.each(['insert', 'update', 'remove'], function (method) {
|
|
m[self._prefix + method] = function (/* ... */) {
|
|
// All the methods do their own validation, instead of using check().
|
|
check(arguments, [Match.Any]);
|
|
var args = _.toArray(arguments);
|
|
try {
|
|
// For an insert, if the client didn't specify an _id, generate one
|
|
// now; because this uses DDP.randomStream, it will be consistent with
|
|
// what the client generated. We generate it now rather than later so
|
|
// that if (eg) an allow/deny rule does an insert to the same
|
|
// collection (not that it really should), the generated _id will
|
|
// still be the first use of the stream and will be consistent.
|
|
//
|
|
// However, we don't actually stick the _id onto the document yet,
|
|
// because we want allow/deny rules to be able to differentiate
|
|
// between arbitrary client-specified _id fields and merely
|
|
// client-controlled-via-randomSeed fields.
|
|
var generatedId = null;
|
|
if (method === "insert" && !_.has(args[0], '_id')) {
|
|
generatedId = self._makeNewID();
|
|
}
|
|
|
|
if (this.isSimulation) {
|
|
// In a client simulation, you can do any mutation (even with a
|
|
// complex selector).
|
|
if (generatedId !== null)
|
|
args[0]._id = generatedId;
|
|
return self._collection[method].apply(
|
|
self._collection, args);
|
|
}
|
|
|
|
// This is the server receiving a method call from the client.
|
|
|
|
// We don't allow arbitrary selectors in mutations from the client: only
|
|
// single-ID selectors.
|
|
if (method !== 'insert')
|
|
throwIfSelectorIsNotId(args[0], method);
|
|
|
|
if (self._restricted) {
|
|
// short circuit if there is no way it will pass.
|
|
if (self._validators[method].allow.length === 0) {
|
|
throw new Meteor.Error(
|
|
403, "Access denied. No allow validators set on restricted " +
|
|
"collection for method '" + method + "'.");
|
|
}
|
|
|
|
var validatedMethodName =
|
|
'_validated' + method.charAt(0).toUpperCase() + method.slice(1);
|
|
args.unshift(this.userId);
|
|
method === 'insert' && args.push(generatedId);
|
|
return self[validatedMethodName].apply(self, args);
|
|
} else if (self._isInsecure()) {
|
|
if (generatedId !== null)
|
|
args[0]._id = generatedId;
|
|
// In insecure mode, allow any mutation (with a simple selector).
|
|
// XXX This is kind of bogus. Instead of blindly passing whatever
|
|
// we get from the network to this function, we should actually
|
|
// know the correct arguments for the function and pass just
|
|
// them. For example, if you have an extraneous extra null
|
|
// argument and this is Mongo on the server, the .wrapAsync'd
|
|
// functions like update will get confused and pass the
|
|
// "fut.resolver()" in the wrong slot, where _update will never
|
|
// invoke it. Bam, broken DDP connection. Probably should just
|
|
// take this whole method and write it three times, invoking
|
|
// helpers for the common code.
|
|
return self._collection[method].apply(self._collection, args);
|
|
} else {
|
|
// In secure mode, if we haven't called allow or deny, then nothing
|
|
// is permitted.
|
|
throw new Meteor.Error(403, "Access denied");
|
|
}
|
|
} catch (e) {
|
|
if (e.name === 'MongoError' || e.name === 'MinimongoError') {
|
|
throw new Meteor.Error(409, e.toString());
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
};
|
|
});
|
|
// Minimongo on the server gets no stubs; instead, by default
|
|
// it wait()s until its result is ready, yielding.
|
|
// This matches the behavior of macromongo on the server better.
|
|
// XXX see #MeteorServerNull
|
|
if (Meteor.isClient || self._connection === Meteor.server)
|
|
self._connection.methods(m);
|
|
}
|
|
};
|
|
|
|
|
|
Mongo.Collection.prototype._updateFetch = function (fields) {
|
|
var self = this;
|
|
|
|
if (!self._validators.fetchAllFields) {
|
|
if (fields) {
|
|
self._validators.fetch = _.union(self._validators.fetch, fields);
|
|
} else {
|
|
self._validators.fetchAllFields = true;
|
|
// clear fetch just to make sure we don't accidentally read it
|
|
self._validators.fetch = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
Mongo.Collection.prototype._isInsecure = function () {
|
|
var self = this;
|
|
if (self._insecure === undefined)
|
|
return !!Package.insecure;
|
|
return self._insecure;
|
|
};
|
|
|
|
var docToValidate = function (validator, doc, generatedId) {
|
|
var ret = doc;
|
|
if (validator.transform) {
|
|
ret = EJSON.clone(doc);
|
|
// If you set a server-side transform on your collection, then you don't get
|
|
// to tell the difference between "client specified the ID" and "server
|
|
// generated the ID", because transforms expect to get _id. If you want to
|
|
// do that check, you can do it with a specific
|
|
// `C.allow({insert: f, transform: null})` validator.
|
|
if (generatedId !== null) {
|
|
ret._id = generatedId;
|
|
}
|
|
ret = validator.transform(ret);
|
|
}
|
|
return ret;
|
|
};
|
|
|
|
Mongo.Collection.prototype._validatedInsert = function (userId, doc,
|
|
generatedId) {
|
|
var self = this;
|
|
|
|
// call user validators.
|
|
// Any deny returns true means denied.
|
|
if (_.any(self._validators.insert.deny, function(validator) {
|
|
return validator(userId, docToValidate(validator, doc, generatedId));
|
|
})) {
|
|
throw new Meteor.Error(403, "Access denied");
|
|
}
|
|
// Any allow returns true means proceed. Throw error if they all fail.
|
|
if (_.all(self._validators.insert.allow, function(validator) {
|
|
return !validator(userId, docToValidate(validator, doc, generatedId));
|
|
})) {
|
|
throw new Meteor.Error(403, "Access denied");
|
|
}
|
|
|
|
// If we generated an ID above, insert it now: after the validation, but
|
|
// before actually inserting.
|
|
if (generatedId !== null)
|
|
doc._id = generatedId;
|
|
|
|
self._collection.insert.call(self._collection, doc);
|
|
};
|
|
|
|
var transformDoc = function (validator, doc) {
|
|
if (validator.transform)
|
|
return validator.transform(doc);
|
|
return doc;
|
|
};
|
|
|
|
// Simulate a mongo `update` operation while validating that the access
|
|
// control rules set by calls to `allow/deny` are satisfied. If all
|
|
// pass, rewrite the mongo operation to use $in to set the list of
|
|
// document ids to change ##ValidatedChange
|
|
Mongo.Collection.prototype._validatedUpdate = function(
|
|
userId, selector, mutator, options) {
|
|
var self = this;
|
|
|
|
check(mutator, Object);
|
|
|
|
options = _.clone(options) || {};
|
|
|
|
if (!LocalCollection._selectorIsIdPerhapsAsObject(selector))
|
|
throw new Error("validated update should be of a single ID");
|
|
|
|
// We don't support upserts because they don't fit nicely into allow/deny
|
|
// rules.
|
|
if (options.upsert)
|
|
throw new Meteor.Error(403, "Access denied. Upserts not " +
|
|
"allowed in a restricted collection.");
|
|
|
|
var noReplaceError = "Access denied. In a restricted collection you can only" +
|
|
" update documents, not replace them. Use a Mongo update operator, such " +
|
|
"as '$set'.";
|
|
|
|
// compute modified fields
|
|
var fields = [];
|
|
if (_.isEmpty(mutator)) {
|
|
throw new Meteor.Error(403, noReplaceError);
|
|
}
|
|
_.each(mutator, function (params, op) {
|
|
if (op.charAt(0) !== '$') {
|
|
throw new Meteor.Error(403, noReplaceError);
|
|
} else if (!_.has(ALLOWED_UPDATE_OPERATIONS, op)) {
|
|
throw new Meteor.Error(
|
|
403, "Access denied. Operator " + op + " not allowed in a restricted collection.");
|
|
} else {
|
|
_.each(_.keys(params), function (field) {
|
|
// treat dotted fields as if they are replacing their
|
|
// top-level part
|
|
if (field.indexOf('.') !== -1)
|
|
field = field.substring(0, field.indexOf('.'));
|
|
|
|
// record the field we are trying to change
|
|
if (!_.contains(fields, field))
|
|
fields.push(field);
|
|
});
|
|
}
|
|
});
|
|
|
|
var findOptions = {transform: null};
|
|
if (!self._validators.fetchAllFields) {
|
|
findOptions.fields = {};
|
|
_.each(self._validators.fetch, function(fieldName) {
|
|
findOptions.fields[fieldName] = 1;
|
|
});
|
|
}
|
|
|
|
var doc = self._collection.findOne(selector, findOptions);
|
|
if (!doc) // none satisfied!
|
|
return 0;
|
|
|
|
var factoriedDoc;
|
|
|
|
// call user validators.
|
|
// Any deny returns true means denied.
|
|
if (_.any(self._validators.update.deny, function(validator) {
|
|
if (!factoriedDoc)
|
|
factoriedDoc = transformDoc(validator, doc);
|
|
return validator(userId,
|
|
factoriedDoc,
|
|
fields,
|
|
mutator);
|
|
})) {
|
|
throw new Meteor.Error(403, "Access denied");
|
|
}
|
|
// Any allow returns true means proceed. Throw error if they all fail.
|
|
if (_.all(self._validators.update.allow, function(validator) {
|
|
if (!factoriedDoc)
|
|
factoriedDoc = transformDoc(validator, doc);
|
|
return !validator(userId,
|
|
factoriedDoc,
|
|
fields,
|
|
mutator);
|
|
})) {
|
|
throw new Meteor.Error(403, "Access denied");
|
|
}
|
|
|
|
options._forbidReplace = true;
|
|
|
|
// Back when we supported arbitrary client-provided selectors, we actually
|
|
// rewrote the selector to include an _id clause before passing to Mongo to
|
|
// avoid races, but since selector is guaranteed to already just be an ID, we
|
|
// don't have to any more.
|
|
|
|
return self._collection.update.call(
|
|
self._collection, selector, mutator, options);
|
|
};
|
|
|
|
// Only allow these operations in validated updates. Specifically
|
|
// whitelist operations, rather than blacklist, so new complex
|
|
// operations that are added aren't automatically allowed. A complex
|
|
// operation is one that does more than just modify its target
|
|
// field. For now this contains all update operations except '$rename'.
|
|
// http://docs.mongodb.org/manual/reference/operators/#update
|
|
var ALLOWED_UPDATE_OPERATIONS = {
|
|
$inc:1, $set:1, $unset:1, $addToSet:1, $pop:1, $pullAll:1, $pull:1,
|
|
$pushAll:1, $push:1, $bit:1
|
|
};
|
|
|
|
// Simulate a mongo `remove` operation while validating access control
|
|
// rules. See #ValidatedChange
|
|
Mongo.Collection.prototype._validatedRemove = function(userId, selector) {
|
|
var self = this;
|
|
|
|
var findOptions = {transform: null};
|
|
if (!self._validators.fetchAllFields) {
|
|
findOptions.fields = {};
|
|
_.each(self._validators.fetch, function(fieldName) {
|
|
findOptions.fields[fieldName] = 1;
|
|
});
|
|
}
|
|
|
|
var doc = self._collection.findOne(selector, findOptions);
|
|
if (!doc)
|
|
return 0;
|
|
|
|
// call user validators.
|
|
// Any deny returns true means denied.
|
|
if (_.any(self._validators.remove.deny, function(validator) {
|
|
return validator(userId, transformDoc(validator, doc));
|
|
})) {
|
|
throw new Meteor.Error(403, "Access denied");
|
|
}
|
|
// Any allow returns true means proceed. Throw error if they all fail.
|
|
if (_.all(self._validators.remove.allow, function(validator) {
|
|
return !validator(userId, transformDoc(validator, doc));
|
|
})) {
|
|
throw new Meteor.Error(403, "Access denied");
|
|
}
|
|
|
|
// Back when we supported arbitrary client-provided selectors, we actually
|
|
// rewrote the selector to {_id: {$in: [ids that we found]}} before passing to
|
|
// Mongo to avoid races, but since selector is guaranteed to already just be
|
|
// an ID, we don't have to any more.
|
|
|
|
return self._collection.remove.call(self._collection, selector);
|
|
};
|
|
|
|
/**
|
|
* @deprecated in 0.9.1
|
|
*/
|
|
Meteor.Collection = Mongo.Collection;
|