UNPKG

mobdb

Version:

MarsDB is a lightweight client-side MongoDB-like database, Promise based, written in ES6

554 lines (516 loc) 16.8 kB
import _check from 'check-types'; import _assign from 'fast.js/object/assign'; import _each from 'fast.js/forEach'; import _every from 'fast.js/array/every'; import EJSON from './EJSON'; import Random from './Random'; import DocumentMatcher from './DocumentMatcher'; import DocumentSorter from './DocumentSorter'; import {isPlainObject, isIndexable, isOperatorObject, isNumericKey, MongoTypeComp} from './Document'; export class DocumentModifier { constructor(query = {}) { this._query = query; this._matcher = new DocumentMatcher(query); } modify(docs, mod = {}, options = {}) { const oldResults = []; const newResults = []; // Regular update _each(docs, (d) => { const match = this._matcher.documentMatches(d); if (match.result) { const matchOpts = _assign( {arrayIndices: match.arrayIndices}, options ); const newDoc = this._modifyDocument(d, mod, matchOpts); newResults.push(newDoc); oldResults.push(d); } }); // Upsert update if (!newResults.length && options.upsert) { let newDoc = documentBySelector(this._query); newDoc._id = newDoc._id || Random.default().id(17); newDoc = this._modifyDocument(newDoc, mod, {isInsert: true}); newResults.push(newDoc); oldResults.push(null); } return { updated: newResults, original: oldResults, }; } // 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. _modifyDocument(doc, mod, options = {}) { if (!isPlainObject(mod)) { throw new Error('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 (!options.isInsert && mod._id && !EJSON.equals(doc._id, mod._id)) { throw new Error('Cannot change the _id of a document'); } // replace the whole document for (var k in mod) { if (/\./.test(k)) { throw new Error( 'When replacing document, field name may not contain \'.\''); } } newDoc = mod; newDoc._id = doc._id; } 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 new Error('Invalid modifier specified ' + op); } _each(operand, function(arg, keypath) { if (keypath === '') { throw new Error('An empty update path is not valid.'); } if (keypath === '_id' && !options.isInsert) { throw new Error('Mod on _id not allowed for update.'); } var keyparts = keypath.split('.'); if (! _every(keyparts, x => x)) { throw new Error( 'The update path \'' + keypath + '\' contains an empty field name, which is not allowed.'); } 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); }); }); } return newDoc; } } export default DocumentModifier; // by given selector returns an object that should // be used for upsert operation var documentBySelector = function(selector) { var selectorDoc = {}; if (!_check.object(selector)) { selector = {_id: selector}; } _each(selector, (v, k) => { if (k.substr(0, 1) !== '$' && !isOperatorObject(v, true)) { const keyparts = k.split('.'); const modTarget = findModTarget(selectorDoc, keyparts); if (modTarget) { modTarget[keyparts[keyparts.length - 1]] = v; } } }); return selectorDoc; }; // 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. export const 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 = new Error( '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 new Error('Too many positional (i.e. \'$\') elements'); } if (!options.arrayIndices || !options.arrayIndices.length) { throw new Error('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, 10); } else { if (options.noCreate) { return undefined; } throw new Error( '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 new Error('can\'t modify field \'' + keyparts[i + 1] + '\' of list value ' + JSON.stringify(doc[keypart])); } } } else { if (keypart.length && keypart.substr(0, 1) === '$') { throw new Error('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 new Error('Modifier $inc allowed for numbers only'); } if (field in target) { if (typeof target[field] !== 'number') { throw new Error('Cannot apply $inc modifier to non-number'); } target[field] += arg; } else { target[field] = arg; } }, $set: function(target, field, arg) { if (!_check.object(target) && !_check.array(target)) { const e = new Error('Cannot set property on non-object field'); e.setPropertyError = true; throw e; } if (target === null) { const e = new Error('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 new Error('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 new Error('$each must be an array'); } // Parse $position var position = undefined; if ('$position' in arg) { if (typeof arg.$position !== 'number') { throw new Error('$position must be a numeric value'); } // XXX should check to make sure integer if (arg.$position < 0) { throw new Error('$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 new Error('$slice must be a numeric value'); } // XXX should check to make sure integer if (arg.$slice > 0) { throw new Error('$slice in $push must be zero or negative'); } slice = arg.$slice; } // Parse $sort. var sortFunction = undefined; if (arg.$sort) { if (slice === undefined) { throw new Error('$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 DocumentSorter(arg.$sort).getComparator(); for (var i = 0; i < toPush.length; i++) { if (MongoTypeComp._type(toPush[i]) !== 3) { throw new Error('$push like modifiers using $sort ' + 'require all elements to be objects'); } } } // Actually push. if (position === undefined) { for (let j = 0; j < toPush.length; j++) { target[field].push(toPush[j]); } } else { var spliceArguments = [position, 0]; for (let 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 new Error('Modifier $pushAll/pullAll allowed for arrays only'); } var x = target[field]; if (x === undefined) { target[field] = arg; } else if (!(x instanceof Array)) { throw new Error('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 new Error('Cannot apply $addToSet modifier to non-array'); } else { _each(values, function(value) { for (let i = 0; i < x.length; i++) { if (MongoTypeComp._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 new Error('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 new Error('Cannot apply $pull/pullAll modifier to non-array'); } else { var out = []; if (arg != null && typeof arg === 'object' && !(arg instanceof Array)) { // XXX would be much nicer to compile this once, rather than // for each document we modify.. but usually we're not // modifying that many documents, so we'll let it slide for // now // XXX Minimongo.Matcher isn't up for the job, because we need // to permit stuff like {$pull: {a: {$gt: 4}}}.. something // like {$gt: 4} is not normally a complete selector. // same issue as $elemMatch possibly? var matcher = new DocumentMatcher(arg); for (let i = 0; i < x.length; i++) { if (!matcher.documentMatches(x[i]).result) { out.push(x[i]); } } } else { for (let i = 0; i < x.length; i++) { if (!MongoTypeComp._equal(x[i], arg)) { out.push(x[i]); } } } target[field] = out; } }, $pullAll: function(target, field, arg) { if (!(typeof arg === 'object' && arg instanceof Array)) { throw new Error('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 new Error('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 (MongoTypeComp._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 new Error('$rename source must differ from target'); } if (target === null) { throw new Error('$rename source field invalid'); } if (typeof arg !== 'string') { throw new Error('$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 new Error('$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 new Error('$bit is not supported'); }, };