Files
meteor/packages/minimongo/modify.js
David Glasser 2412753f6a Clone "replacement" doc in minimongo update
Previously we were carefully cloning the relevant pieces of most
modifier $ops but not for replacement (or for $pushAll, for that
matter). Instead, just clone the full mod doc always.

(The purpose of cloning here is so that mutable state isn't shared
between the arguments to minimongo APIs and internal LocalCollection
data structures.)

Fixes #4377.
2015-05-19 14:02:33 -07:00

438 lines
14 KiB
JavaScript

// XXX need a strategy for passing the binding of $ into this
// function, from the compiled selector
//
// maybe just {key.up.to.just.before.dollarsign: array_index}
//
// XXX atomicity: if one modification fails, do we roll back the whole
// change?
//
// options:
// - isInsert is set when _modify is being called to compute the document to
// insert as part of an upsert operation. We use this primarily to figure
// out when to set the fields in $setOnInsert, if present.
LocalCollection._modify = function (doc, mod, options) {
options = options || {};
if (!isPlainObject(mod))
throw MinimongoError("Modifier must be an object");
// Make sure the caller can't mutate our data structures.
mod = EJSON.clone(mod);
var isModifier = isOperatorObject(mod);
var newDoc;
if (!isModifier) {
if (mod._id && !EJSON.equals(doc._id, mod._id))
throw MinimongoError("Cannot change the _id of a document");
// replace the whole document
for (var k in mod) {
if (/\./.test(k))
throw MinimongoError(
"When replacing document, field name may not contain '.'");
}
newDoc = mod;
} else {
// apply modifiers to the doc.
newDoc = EJSON.clone(doc);
_.each(mod, function (operand, op) {
var modFunc = MODIFIERS[op];
// Treat $setOnInsert as $set if this is an insert.
if (options.isInsert && op === '$setOnInsert')
modFunc = MODIFIERS['$set'];
if (!modFunc)
throw MinimongoError("Invalid modifier specified " + op);
_.each(operand, function (arg, keypath) {
if (keypath === '') {
throw MinimongoError("An empty update path is not valid.");
}
if (keypath === '_id') {
throw MinimongoError("Mod on _id not allowed");
}
var keyparts = keypath.split('.');
if (! _.all(keyparts, _.identity)) {
throw MinimongoError(
"The update path '" + keypath +
"' contains an empty field name, which is not allowed.");
}
var noCreate = _.has(NO_CREATE_MODIFIERS, op);
var forbidArray = (op === "$rename");
var target = findModTarget(newDoc, keyparts, {
noCreate: NO_CREATE_MODIFIERS[op],
forbidArray: (op === "$rename"),
arrayIndices: options.arrayIndices
});
var field = keyparts.pop();
modFunc(target, field, arg, keypath, newDoc);
});
});
}
// move new document into place.
_.each(_.keys(doc), function (k) {
// Note: this used to be for (var k in doc) however, this does not
// work right in Opera. Deleting from a doc while iterating over it
// would sometimes cause opera to skip some keys.
if (k !== '_id')
delete doc[k];
});
_.each(newDoc, function (v, k) {
doc[k] = v;
});
};
// for a.b.c.2.d.e, keyparts should be ['a', 'b', 'c', '2', 'd', 'e'],
// and then you would operate on the 'e' property of the returned
// object.
//
// if options.noCreate is falsey, creates intermediate levels of
// structure as necessary, like mkdir -p (and raises an exception if
// that would mean giving a non-numeric property to an array.) if
// options.noCreate is true, return undefined instead.
//
// may modify the last element of keyparts to signal to the caller that it needs
// to use a different value to index into the returned object (for example,
// ['a', '01'] -> ['a', 1]).
//
// if forbidArray is true, return null if the keypath goes through an array.
//
// if options.arrayIndices is set, use its first element for the (first) '$' in
// the path.
var findModTarget = function (doc, keyparts, options) {
options = options || {};
var usedArrayIndex = false;
for (var i = 0; i < keyparts.length; i++) {
var last = (i === keyparts.length - 1);
var keypart = keyparts[i];
var indexable = isIndexable(doc);
if (!indexable) {
if (options.noCreate)
return undefined;
var e = MinimongoError(
"cannot use the part '" + keypart + "' to traverse " + doc);
e.setPropertyError = true;
throw e;
}
if (doc instanceof Array) {
if (options.forbidArray)
return null;
if (keypart === '$') {
if (usedArrayIndex)
throw MinimongoError("Too many positional (i.e. '$') elements");
if (!options.arrayIndices || !options.arrayIndices.length) {
throw MinimongoError("The positional operator did not find the " +
"match needed from the query");
}
keypart = options.arrayIndices[0];
usedArrayIndex = true;
} else if (isNumericKey(keypart)) {
keypart = parseInt(keypart);
} else {
if (options.noCreate)
return undefined;
throw MinimongoError(
"can't append to array using string field name ["
+ keypart + "]");
}
if (last)
// handle 'a.01'
keyparts[i] = keypart;
if (options.noCreate && keypart >= doc.length)
return undefined;
while (doc.length < keypart)
doc.push(null);
if (!last) {
if (doc.length === keypart)
doc.push({});
else if (typeof doc[keypart] !== "object")
throw MinimongoError("can't modify field '" + keyparts[i + 1] +
"' of list value " + JSON.stringify(doc[keypart]));
}
} else {
if (keypart.length && keypart.substr(0, 1) === '$')
throw MinimongoError("can't set field named " + keypart);
if (!(keypart in doc)) {
if (options.noCreate)
return undefined;
if (!last)
doc[keypart] = {};
}
}
if (last)
return doc;
doc = doc[keypart];
}
// notreached
};
var NO_CREATE_MODIFIERS = {
$unset: true,
$pop: true,
$rename: true,
$pull: true,
$pullAll: true
};
var MODIFIERS = {
$inc: function (target, field, arg) {
if (typeof arg !== "number")
throw MinimongoError("Modifier $inc allowed for numbers only");
if (field in target) {
if (typeof target[field] !== "number")
throw MinimongoError("Cannot apply $inc modifier to non-number");
target[field] += arg;
} else {
target[field] = arg;
}
},
$set: function (target, field, arg) {
if (!_.isObject(target)) { // not an array or an object
var e = MinimongoError("Cannot set property on non-object field");
e.setPropertyError = true;
throw e;
}
if (target === null) {
var e = MinimongoError("Cannot set property on null");
e.setPropertyError = true;
throw e;
}
target[field] = arg;
},
$setOnInsert: function (target, field, arg) {
// converted to `$set` in `_modify`
},
$unset: function (target, field, arg) {
if (target !== undefined) {
if (target instanceof Array) {
if (field in target)
target[field] = null;
} else
delete target[field];
}
},
$push: function (target, field, arg) {
if (target[field] === undefined)
target[field] = [];
if (!(target[field] instanceof Array))
throw MinimongoError("Cannot apply $push modifier to non-array");
if (!(arg && arg.$each)) {
// Simple mode: not $each
target[field].push(arg);
return;
}
// Fancy mode: $each (and maybe $slice and $sort and $position)
var toPush = arg.$each;
if (!(toPush instanceof Array))
throw MinimongoError("$each must be an array");
// Parse $position
var position = undefined;
if ('$position' in arg) {
if (typeof arg.$position !== "number")
throw MinimongoError("$position must be a numeric value");
// XXX should check to make sure integer
if (arg.$position < 0)
throw MinimongoError("$position in $push must be zero or positive");
position = arg.$position;
}
// Parse $slice.
var slice = undefined;
if ('$slice' in arg) {
if (typeof arg.$slice !== "number")
throw MinimongoError("$slice must be a numeric value");
// XXX should check to make sure integer
if (arg.$slice > 0)
throw MinimongoError("$slice in $push must be zero or negative");
slice = arg.$slice;
}
// Parse $sort.
var sortFunction = undefined;
if (arg.$sort) {
if (slice === undefined)
throw MinimongoError("$sort requires $slice to be present");
// XXX this allows us to use a $sort whose value is an array, but that's
// actually an extension of the Node driver, so it won't work
// server-side. Could be confusing!
// XXX is it correct that we don't do geo-stuff here?
sortFunction = new Minimongo.Sorter(arg.$sort).getComparator();
for (var i = 0; i < toPush.length; i++) {
if (LocalCollection._f._type(toPush[i]) !== 3) {
throw MinimongoError("$push like modifiers using $sort " +
"require all elements to be objects");
}
}
}
// Actually push.
if (position === undefined) {
for (var j = 0; j < toPush.length; j++)
target[field].push(toPush[j]);
} else {
var spliceArguments = [position, 0];
for (var j = 0; j < toPush.length; j++)
spliceArguments.push(toPush[j]);
Array.prototype.splice.apply(target[field], spliceArguments);
}
// Actually sort.
if (sortFunction)
target[field].sort(sortFunction);
// Actually slice.
if (slice !== undefined) {
if (slice === 0)
target[field] = []; // differs from Array.slice!
else
target[field] = target[field].slice(slice);
}
},
$pushAll: function (target, field, arg) {
if (!(typeof arg === "object" && arg instanceof Array))
throw MinimongoError("Modifier $pushAll/pullAll allowed for arrays only");
var x = target[field];
if (x === undefined)
target[field] = arg;
else if (!(x instanceof Array))
throw MinimongoError("Cannot apply $pushAll modifier to non-array");
else {
for (var i = 0; i < arg.length; i++)
x.push(arg[i]);
}
},
$addToSet: function (target, field, arg) {
var isEach = false;
if (typeof arg === "object") {
//check if first key is '$each'
for (var k in arg) {
if (k === "$each")
isEach = true;
break;
}
}
var values = isEach ? arg["$each"] : [arg];
var x = target[field];
if (x === undefined)
target[field] = values;
else if (!(x instanceof Array))
throw MinimongoError("Cannot apply $addToSet modifier to non-array");
else {
_.each(values, function (value) {
for (var i = 0; i < x.length; i++)
if (LocalCollection._f._equal(value, x[i]))
return;
x.push(value);
});
}
},
$pop: function (target, field, arg) {
if (target === undefined)
return;
var x = target[field];
if (x === undefined)
return;
else if (!(x instanceof Array))
throw MinimongoError("Cannot apply $pop modifier to non-array");
else {
if (typeof arg === 'number' && arg < 0)
x.splice(0, 1);
else
x.pop();
}
},
$pull: function (target, field, arg) {
if (target === undefined)
return;
var x = target[field];
if (x === undefined)
return;
else if (!(x instanceof Array))
throw MinimongoError("Cannot apply $pull/pullAll modifier to non-array");
else {
var out = [];
if (typeof arg === "object" && !(arg instanceof Array)) {
// XXX would be much nicer to compile this once, rather than
// for each document we modify.. but usually we're not
// modifying that many documents, so we'll let it slide for
// now
// XXX Minimongo.Matcher isn't up for the job, because we need
// to permit stuff like {$pull: {a: {$gt: 4}}}.. something
// like {$gt: 4} is not normally a complete selector.
// same issue as $elemMatch possibly?
var matcher = new Minimongo.Matcher(arg);
for (var i = 0; i < x.length; i++)
if (!matcher.documentMatches(x[i]).result)
out.push(x[i]);
} else {
for (var i = 0; i < x.length; i++)
if (!LocalCollection._f._equal(x[i], arg))
out.push(x[i]);
}
target[field] = out;
}
},
$pullAll: function (target, field, arg) {
if (!(typeof arg === "object" && arg instanceof Array))
throw MinimongoError("Modifier $pushAll/pullAll allowed for arrays only");
if (target === undefined)
return;
var x = target[field];
if (x === undefined)
return;
else if (!(x instanceof Array))
throw MinimongoError("Cannot apply $pull/pullAll modifier to non-array");
else {
var out = [];
for (var i = 0; i < x.length; i++) {
var exclude = false;
for (var j = 0; j < arg.length; j++) {
if (LocalCollection._f._equal(x[i], arg[j])) {
exclude = true;
break;
}
}
if (!exclude)
out.push(x[i]);
}
target[field] = out;
}
},
$rename: function (target, field, arg, keypath, doc) {
if (keypath === arg)
// no idea why mongo has this restriction..
throw MinimongoError("$rename source must differ from target");
if (target === null)
throw MinimongoError("$rename source field invalid");
if (typeof arg !== "string")
throw MinimongoError("$rename target must be a string");
if (target === undefined)
return;
var v = target[field];
delete target[field];
var keyparts = arg.split('.');
var target2 = findModTarget(doc, keyparts, {forbidArray: true});
if (target2 === null)
throw MinimongoError("$rename target field invalid");
var field2 = keyparts.pop();
target2[field2] = v;
},
$bit: function (target, field, arg) {
// XXX mongo only supports $bit on integers, and we only support
// native javascript numbers (doubles) so far, so we can't support $bit
throw MinimongoError("$bit is not supported");
}
};