├── .babelrc ├── .gitignore ├── .npmignore ├── .wallaby.js ├── LICENSE ├── README.md ├── bundle.js ├── package.json ├── rollup.config.prod.js └── src ├── ModifyJsError.js ├── helpers.js ├── lib ├── each.js ├── every.js ├── has.js ├── isArray.js ├── isBinary.js ├── isObject.js ├── isPlainObject.js └── keys.js ├── modify.js └── modify.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "dev": { 4 | "presets": [ 5 | "es2015", 6 | "stage-2" 7 | ] 8 | }, 9 | "rollup": { 10 | "presets": [ 11 | ["latest", { 12 | "es2015": { 13 | "modules": false 14 | } 15 | } 16 | ], 17 | "stage-2" 18 | ], 19 | "plugins": ["external-helpers"] 20 | } 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src -------------------------------------------------------------------------------- /.wallaby.js: -------------------------------------------------------------------------------- 1 | process.env.BABEL_ENV = 'dev'; 2 | 3 | module.exports = function (wallaby) { 4 | 5 | return { 6 | files: ['src/**/*.js', '!src/*.test.js'], 7 | 8 | tests: ['src/*.test.js'], 9 | 10 | env: { 11 | type: 'node', 12 | runner: 'node', 13 | }, 14 | compilers: { 15 | '**/*.js': wallaby.compilers.babel() 16 | }, 17 | testFramework: 'jest' 18 | }; 19 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Łukasz Gandecki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # modifyjs 2 | ![Circle CI](https://circleci.com/gh/lgandecki/modifyjs.svg?style=shield) 3 | 4 | Modify your objects with a mongo like syntax. This is based on a modify function of Meteor's brilliant minimongo package, cleaned up, rewritten to es6, changed to work without Meteor context, and included nice, readable tests based on a mongodb documentation. 5 | 6 | ### Usage 7 | The usage is shown in the [src/modify.test.js](src/modify.test.js) file, but to show a simple example: 8 | 9 | ```javascript 10 | import modify from 'modifyjs'; 11 | 12 | const myObject = { _id: 1, scores: [ 0, 2, 5, 5, 1, 0 ] }; 13 | 14 | const updatedObject = modify(myObject, {$pullAll: {scores: [0, 5]}}); 15 | 16 | const expectedObject = {_id: 1, scores: [2, 1]}; 17 | expect(updatedObject).toEqual(expectedObject); 18 | ``` 19 | 20 | ### Installation 21 | ``` 22 | npm install modifyjs 23 | ``` 24 | 25 | ### Implemented: 26 | ``` 27 | $min 28 | ✓ updates a field when the passed value is lower than an existing one (3ms) 29 | ✓ doesn't update a field when the passed value is higher than an existing one 30 | $max 31 | ✓ updates a field when the passed value is higher than an existing one 32 | ✓ doesn't update a field when the passed value is lower than an existing one (1ms) 33 | $inc 34 | ✓ can increment with positive and negative values at the same time 35 | $set 36 | ✓ sets top-level fields (1ms) 37 | ✓ sets fields in embedded documents (1ms) 38 | ✓ sets elements in arrays 39 | $unset 40 | ✓ deletes a particular field (1ms) 41 | $push 42 | ✓ appends a value to an array 43 | ✓ appends multiple values to an array (1ms) 44 | $pushAll 45 | ✓ appends multiple values to an array without $each 46 | $addToSet 47 | ✓ appends array with an array 48 | ✓ adds element to an array if element doesn't already exist (1ms) 49 | ✓ doesn't add an element to the array if it does already exists 50 | ✓ adds multiple values to the array field with $each modifier, omitting existing ones 51 | $pop 52 | ✓ removes the first element from an array (1ms) 53 | ✓ removes the last item of an array 54 | $pullAll 55 | ✓ removes all instances of the specified values from an existing array (1ms) 56 | $rename 57 | ✓ updates the name of a field 58 | ``` 59 | 60 | ### Not implemented yet: 61 | ``` 62 | $currentDate 63 | $pull (but $pullAll works) 64 | $push with $sort modifier 65 | ``` 66 | -------------------------------------------------------------------------------- /bundle.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var underscore = require('underscore'); 4 | 5 | /** 6 | * Checks if `value` is the 7 | * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) 8 | * of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) 9 | * 10 | * @static 11 | * @memberOf _ 12 | * @since 0.1.0 13 | * @category Lang 14 | * @param {*} value The value to check. 15 | * @returns {boolean} Returns `true` if `value` is an object, else `false`. 16 | * @example 17 | * 18 | * _.isObject({}); 19 | * // => true 20 | * 21 | * _.isObject([1, 2, 3]); 22 | * // => true 23 | * 24 | * _.isObject(_.noop); 25 | * // => true 26 | * 27 | * _.isObject(null); 28 | * // => false 29 | */ 30 | function isObject(value) { 31 | var type = typeof value; 32 | return value != null && (type == 'object' || type == 'function'); 33 | } 34 | 35 | MinimongoError = function MinimongoError(message) { 36 | var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 37 | 38 | if (typeof message === "string" && options.field) { 39 | message += " for field '" + options.field + "'"; 40 | } 41 | 42 | var e = new Error(message); 43 | e.name = "MinimongoError"; 44 | return e; 45 | }; 46 | 47 | var MinimongoError$1 = MinimongoError; 48 | 49 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { 50 | return typeof obj; 51 | } : function (obj) { 52 | return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; 53 | }; 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | var _extends = Object.assign || function (target) { 76 | for (var i = 1; i < arguments.length; i++) { 77 | var source = arguments[i]; 78 | 79 | for (var key in source) { 80 | if (Object.prototype.hasOwnProperty.call(source, key)) { 81 | target[key] = source[key]; 82 | } 83 | } 84 | } 85 | 86 | return target; 87 | }; 88 | 89 | // Make sure field names do not contain Mongo restricted 90 | // characters ('.', '$', '\0'). 91 | // https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names 92 | var invalidCharMsg = { 93 | '.': "contain '.'", 94 | '$': "start with '$'", 95 | '\0': "contain null bytes" 96 | }; 97 | function assertIsValidFieldName(key) { 98 | var match = void 0; 99 | if (underscore.isString(key) && (match = key.match(/^\$|\.|\0/))) { 100 | throw MinimongoError$1('Key ' + key + ' must not ' + invalidCharMsg[match[0]]); 101 | } 102 | } 103 | 104 | // checks if all field names in an object are valid 105 | function assertHasValidFieldNames(doc) { 106 | if (doc && (typeof doc === 'undefined' ? 'undefined' : _typeof(doc)) === "object") { 107 | JSON.stringify(doc, function (key, value) { 108 | assertIsValidFieldName(key); 109 | return value; 110 | }); 111 | } 112 | } 113 | 114 | EJSON = {}; 115 | EJSONTest = {}; 116 | 117 | // Add a custom type, using a method of your choice to get to and 118 | // from a basic JSON-able representation. The factory argument 119 | // is a function of JSON-able --> your object 120 | // The type you add must have: 121 | // - A toJSONValue() method, so that Meteor can serialize it 122 | // - a typeName() method, to show how to look it up in our type table. 123 | // It is okay if these methods are monkey-patched on. 124 | // EJSON.clone will use toJSONValue and the given factory to produce 125 | // a clone, but you may specify a method clone() that will be 126 | // used instead. 127 | // Similarly, EJSON.equals will use toJSONValue to make comparisons, 128 | // but you may provide a method equals() instead. 129 | /** 130 | * @summary Add a custom datatype to EJSON. 131 | * @locus Anywhere 132 | * @param {String} name A tag for your custom type; must be unique among custom data types defined in your project, and must match the result of your type's `typeName` method. 133 | * @param {Function} factory A function that deserializes a JSON-compatible value into an instance of your type. This should match the serialization performed by your type's `toJSONValue` method. 134 | */ 135 | 136 | var _$2 = { has: underscore.has, isNaN: underscore.isNaN, size: underscore.size, isEmpty: underscore.isEmpty, any: underscore.any, each: underscore.each, all: underscore.all, isArguments: underscore.isArguments, isArray: underscore.isArray }; 137 | 138 | EJSON.isBinary = function (obj) { 139 | return !!(typeof Uint8Array !== 'undefined' && obj instanceof Uint8Array || obj && obj.$Uint8ArrayPolyfill); 140 | }; 141 | 142 | /** 143 | * @summary Return true if `a` and `b` are equal to each other. Return false otherwise. Uses the `equals` method on `a` if present, otherwise performs a deep comparison. 144 | * @locus Anywhere 145 | * @param {EJSON} a 146 | * @param {EJSON} b 147 | * @param {Object} [options] 148 | * @param {Boolean} options.keyOrderSensitive Compare in key sensitive order, if supported by the JavaScript implementation. For example, `{a: 1, b: 2}` is equal to `{b: 2, a: 1}` only when `keyOrderSensitive` is `false`. The default is `false`. 149 | */ 150 | EJSON.equals = function (a, b, options) { 151 | var i; 152 | var keyOrderSensitive = !!(options && options.keyOrderSensitive); 153 | if (a === b) return true; 154 | if (_$2.isNaN(a) && _$2.isNaN(b)) return true; // This differs from the IEEE spec for NaN equality, b/c we don't want 155 | // anything ever with a NaN to be poisoned from becoming equal to anything. 156 | if (!a || !b) // if either one is falsy, they'd have to be === to be equal 157 | return false; 158 | if (!((typeof a === 'undefined' ? 'undefined' : _typeof(a)) === 'object' && (typeof b === 'undefined' ? 'undefined' : _typeof(b)) === 'object')) return false; 159 | if (a instanceof Date && b instanceof Date) return a.valueOf() === b.valueOf(); 160 | if (EJSON.isBinary(a) && EJSON.isBinary(b)) { 161 | if (a.length !== b.length) return false; 162 | for (i = 0; i < a.length; i++) { 163 | if (a[i] !== b[i]) return false; 164 | } 165 | return true; 166 | } 167 | if (typeof a.equals === 'function') return a.equals(b, options); 168 | if (typeof b.equals === 'function') return b.equals(a, options); 169 | if (a instanceof Array) { 170 | if (!(b instanceof Array)) return false; 171 | if (a.length !== b.length) return false; 172 | for (i = 0; i < a.length; i++) { 173 | if (!EJSON.equals(a[i], b[i], options)) return false; 174 | } 175 | return true; 176 | } 177 | 178 | // fall back to structural equality of objects 179 | var ret; 180 | if (keyOrderSensitive) { 181 | var bKeys = []; 182 | _$2.each(b, function (val, x) { 183 | bKeys.push(x); 184 | }); 185 | i = 0; 186 | ret = _$2.all(a, function (val, x) { 187 | if (i >= bKeys.length) { 188 | return false; 189 | } 190 | if (x !== bKeys[i]) { 191 | return false; 192 | } 193 | if (!EJSON.equals(val, b[bKeys[i]], options)) { 194 | return false; 195 | } 196 | i++; 197 | return true; 198 | }); 199 | return ret && i === bKeys.length; 200 | } else { 201 | i = 0; 202 | ret = _$2.all(a, function (val, key) { 203 | if (!_$2.has(b, key)) { 204 | return false; 205 | } 206 | if (!EJSON.equals(val, b[key], options)) { 207 | return false; 208 | } 209 | i++; 210 | return true; 211 | }); 212 | return ret && _$2.size(b) === i; 213 | } 214 | }; 215 | 216 | /** 217 | * @summary Return a deep copy of `val`. 218 | * @locus Anywhere 219 | * @param {EJSON} val A value to copy. 220 | */ 221 | EJSON.clone = function (v) { 222 | var ret; 223 | if ((typeof v === 'undefined' ? 'undefined' : _typeof(v)) !== "object") return v; 224 | if (v === null) return null; // null has typeof "object" 225 | if (v instanceof Date) return new Date(v.getTime()); 226 | // RegExps are not really EJSON elements (eg we don't define a serialization 227 | // for them), but they're immutable anyway, so we can support them in clone. 228 | if (v instanceof RegExp) return v; 229 | if (EJSON.isBinary(v)) { 230 | ret = EJSON.newBinary(v.length); 231 | for (var i = 0; i < v.length; i++) { 232 | ret[i] = v[i]; 233 | } 234 | return ret; 235 | } 236 | // XXX: Use something better than underscore's isArray 237 | if (_$2.isArray(v) || _$2.isArguments(v)) { 238 | // For some reason, _.map doesn't work in this context on Opera (weird test 239 | // failures). 240 | ret = []; 241 | for (i = 0; i < v.length; i++) { 242 | ret[i] = EJSON.clone(v[i]); 243 | }return ret; 244 | } 245 | // handle general user-defined typed Objects if they have a clone method 246 | if (typeof v.clone === 'function') { 247 | return v.clone(); 248 | } 249 | 250 | // handle other objects 251 | ret = {}; 252 | _$2.each(v, function (value, key) { 253 | ret[key] = EJSON.clone(value); 254 | }); 255 | return ret; 256 | }; 257 | 258 | var _f = { 259 | // XXX for _all and _in, consider building 'inquery' at compile time.. 260 | 261 | _type: function _type(v) { 262 | if (typeof v === "number") return 1; 263 | if (typeof v === "string") return 2; 264 | if (typeof v === "boolean") return 8; 265 | if (isArray(v)) return 4; 266 | if (v === null) return 10; 267 | if (v instanceof RegExp) 268 | // note that typeof(/x/) === "object" 269 | return 11; 270 | if (typeof v === "function") return 13; 271 | if (v instanceof Date) return 9; 272 | if (EJSON.isBinary(v)) return 5; 273 | return 3; // object 274 | 275 | // XXX support some/all of these: 276 | // 14, symbol 277 | // 15, javascript code with scope 278 | // 16, 18: 32-bit/64-bit integer 279 | // 17, timestamp 280 | // 255, minkey 281 | // 127, maxkey 282 | }, 283 | 284 | // deep equality test: use for literal document and array matches 285 | _equal: function _equal(a, b) { 286 | return EJSON.equals(a, b, { keyOrderSensitive: true }); 287 | }, 288 | 289 | // maps a type code to a value that can be used to sort values of 290 | // different types 291 | _typeorder: function _typeorder(t) { 292 | // http://www.mongodb.org/display/DOCS/What+is+the+Compare+Order+for+BSON+Types 293 | // XXX what is the correct sort position for Javascript code? 294 | // ('100' in the matrix below) 295 | // XXX minkey/maxkey 296 | return [-1, // (not a type) 297 | 1, // number 298 | 2, // string 299 | 3, // object 300 | 4, // array 301 | 5, // binary 302 | -1, // deprecated 303 | 6, // ObjectID 304 | 7, // bool 305 | 8, // Date 306 | 0, // null 307 | 9, // RegExp 308 | -1, // deprecated 309 | 100, // JS code 310 | 2, // deprecated (symbol) 311 | 100, // JS code 312 | 1, // 32-bit int 313 | 8, // Mongo timestamp 314 | 1 // 64-bit int 315 | ][t]; 316 | }, 317 | 318 | // compare two values of unknown type according to BSON ordering 319 | // semantics. (as an extension, consider 'undefined' to be less than 320 | // any other value.) return negative if a is less, positive if b is 321 | // less, or 0 if equal 322 | _cmp: function _cmp(a, b) { 323 | if (a === undefined) return b === undefined ? 0 : -1; 324 | if (b === undefined) return 1; 325 | var ta = this._type(a); 326 | var tb = this._type(b); 327 | var oa = this._typeorder(ta); 328 | var ob = this._typeorder(tb); 329 | if (oa !== ob) return oa < ob ? -1 : 1; 330 | if (ta !== tb) 331 | // XXX need to implement this if we implement Symbol or integers, or 332 | // Timestamp 333 | throw Error("Missing type coercion logic in _cmp"); 334 | if (ta === 7) { 335 | // ObjectID 336 | // Convert to string. 337 | ta = tb = 2; 338 | a = a.toHexString(); 339 | b = b.toHexString(); 340 | } 341 | if (ta === 9) { 342 | // Date 343 | // Convert to millis. 344 | ta = tb = 1; 345 | a = a.getTime(); 346 | b = b.getTime(); 347 | } 348 | 349 | if (ta === 1) // double 350 | return a - b; 351 | if (tb === 2) // string 352 | return a < b ? -1 : a === b ? 0 : 1; 353 | if (ta === 3) { 354 | // Object 355 | // this could be much more efficient in the expected case ... 356 | var to_array = function to_array(obj) { 357 | var ret = []; 358 | for (var key in obj) { 359 | ret.push(key); 360 | ret.push(obj[key]); 361 | } 362 | return ret; 363 | }; 364 | return this._cmp(to_array(a), to_array(b)); 365 | } 366 | if (ta === 4) { 367 | // Array 368 | for (var i = 0;; i++) { 369 | if (i === a.length) return i === b.length ? 0 : -1; 370 | if (i === b.length) return 1; 371 | var s = this._cmp(a[i], b[i]); 372 | if (s !== 0) return s; 373 | } 374 | } 375 | if (ta === 5) { 376 | // binary 377 | // Surprisingly, a small binary blob is always less than a large one in 378 | // Mongo. 379 | if (a.length !== b.length) return a.length - b.length; 380 | for (i = 0; i < a.length; i++) { 381 | if (a[i] < b[i]) return -1; 382 | if (a[i] > b[i]) return 1; 383 | } 384 | return 0; 385 | } 386 | if (ta === 8) { 387 | // boolean 388 | if (a) return b ? 0 : 1; 389 | return b ? -1 : 0; 390 | } 391 | if (ta === 10) // null 392 | return 0; 393 | if (ta === 11) // regexp 394 | throw Error("Sorting not supported on regular expression"); // XXX 395 | // 13: javascript code 396 | // 14: symbol 397 | // 15: javascript code with scope 398 | // 16: 32-bit integer 399 | // 17: timestamp 400 | // 18: 64-bit integer 401 | // 255: minkey 402 | // 127: maxkey 403 | if (ta === 13) // javascript code 404 | throw Error("Sorting not supported on Javascript code"); // XXX 405 | throw Error("Unknown type to sort"); 406 | } 407 | }; 408 | 409 | var _$1 = { isArray: underscore.isArray, each: underscore.each }; 410 | // Like _.isArray, but doesn't regard polyfilled Uint8Arrays on old browsers as 411 | // arrays. 412 | // XXX maybe this should be EJSON.isArray 413 | isArray = function isArray(x) { 414 | return _$1.isArray(x) && !EJSON.isBinary(x); 415 | }; 416 | // XXX maybe this should be EJSON.isObject, though EJSON doesn't know about 417 | // RegExp 418 | // XXX note that _type(undefined) === 3!!!! 419 | isPlainObject = function isPlainObject(x) { 420 | return x && _f._type(x) === 3; 421 | }; 422 | isIndexable = function isIndexable(x) { 423 | return isArray(x) || isPlainObject(x); 424 | }; 425 | // Returns true if this is an object with at least one key and all keys begin 426 | // with $. Unless inconsistentOK is set, throws if some keys begin with $ and 427 | // others don't. 428 | isOperatorObject = function isOperatorObject(valueSelector, inconsistentOK) { 429 | if (!isPlainObject(valueSelector)) return false; 430 | 431 | var theseAreOperators = undefined; 432 | _$1.each(valueSelector, function (value, selKey) { 433 | var thisIsOperator = selKey.substr(0, 1) === '$'; 434 | if (theseAreOperators === undefined) { 435 | theseAreOperators = thisIsOperator; 436 | } else if (theseAreOperators !== thisIsOperator) { 437 | if (!inconsistentOK) throw new Error("Inconsistent operator: " + JSON.stringify(valueSelector)); 438 | theseAreOperators = false; 439 | } 440 | }); 441 | return !!theseAreOperators; // {} has no operators 442 | }; 443 | 444 | // string can be converted to integer 445 | isNumericKey = function isNumericKey(s) { 446 | return (/^[0-9]+$/.test(s) 447 | ); 448 | }; 449 | 450 | var _ = { all: underscore.all, each: underscore.each, keys: underscore.keys, has: underscore.has, isObject: isObject }; 451 | // XXX need a strategy for passing the binding of $ into this 452 | // function, from the compiled selector 453 | // 454 | // maybe just {key.up.to.just.before.dollarsign: array_index} 455 | // 456 | // XXX atomicity: if one modification fails, do we roll back the whole 457 | // change? 458 | // 459 | // options: 460 | // - isInsert is set when _modify is being called to compute the document to 461 | // insert as part of an upsert operation. We use this primarily to figure 462 | // out when to set the fields in $setOnInsert, if present. 463 | var modify = function (doc, mod, options) { 464 | return LocalCollection._modify(doc, mod, _extends({}, options, { returnInsteadOfReplacing: true })); 465 | }; 466 | 467 | LocalCollection = window && window.LocalCollection || global && global.LocalCollection || {}; 468 | 469 | LocalCollection._modify = function (doc, mod, options) { 470 | options = options || {}; 471 | if (!isPlainObject(mod)) throw MinimongoError$1("Modifier must be an object"); 472 | 473 | // Make sure the caller can't mutate our data structures. 474 | mod = EJSON.clone(mod); 475 | 476 | var isModifier = isOperatorObject(mod); 477 | 478 | var newDoc; 479 | 480 | if (!isModifier) { 481 | // replace the whole document 482 | assertHasValidFieldNames(mod); 483 | newDoc = mod; 484 | } else { 485 | // apply modifiers to the doc. 486 | newDoc = EJSON.clone(doc); 487 | _.each(mod, function (operand, op) { 488 | var modFunc = MODIFIERS[op]; 489 | // Treat $setOnInsert as $set if this is an insert. 490 | if (!modFunc) throw MinimongoError$1("Invalid modifier specified " + op); 491 | _.each(operand, function (arg, keypath) { 492 | if (keypath === '') { 493 | throw MinimongoError$1("An empty update path is not valid."); 494 | } 495 | 496 | var keyparts = keypath.split('.'); 497 | if (!underscore.all(keyparts)) { 498 | throw MinimongoError$1("The update path '" + keypath + "' contains an empty field name, which is not allowed."); 499 | } 500 | 501 | var noCreate = _.has(NO_CREATE_MODIFIERS, op); 502 | var forbidArray = op === "$rename"; 503 | var target = findModTarget(newDoc, keyparts, { 504 | noCreate: NO_CREATE_MODIFIERS[op], 505 | forbidArray: op === "$rename", 506 | arrayIndices: options.arrayIndices 507 | }); 508 | var field = keyparts.pop(); 509 | modFunc(target, field, arg, keypath, newDoc); 510 | }); 511 | }); 512 | } 513 | 514 | if (options.returnInsteadOfReplacing) { 515 | return newDoc; 516 | } else { 517 | // move new document into place. 518 | _.each(_.keys(doc), function (k) { 519 | // Note: this used to be for (var k in doc) however, this does not 520 | // work right in Opera. Deleting from a doc while iterating over it 521 | // would sometimes cause opera to skip some keys. 522 | if (k !== '_id') delete doc[k]; 523 | }); 524 | _.each(newDoc, function (v, k) { 525 | doc[k] = v; 526 | }); 527 | } 528 | }; 529 | 530 | // for a.b.c.2.d.e, keyparts should be ['a', 'b', 'c', '2', 'd', 'e'], 531 | // and then you would operate on the 'e' property of the returned 532 | // object. 533 | // 534 | // if options.noCreate is falsey, creates intermediate levels of 535 | // structure as necessary, like mkdir -p (and raises an exception if 536 | // that would mean giving a non-numeric property to an array.) if 537 | // options.noCreate is true, return undefined instead. 538 | // 539 | // may modify the last element of keyparts to signal to the caller that it needs 540 | // to use a different value to index into the returned object (for example, 541 | // ['a', '01'] -> ['a', 1]). 542 | // 543 | // if forbidArray is true, return null if the keypath goes through an array. 544 | // 545 | // if options.arrayIndices is set, use its first element for the (first) '$' in 546 | // the path. 547 | var findModTarget = function findModTarget(doc, keyparts, options) { 548 | options = options || {}; 549 | var usedArrayIndex = false; 550 | for (var i = 0; i < keyparts.length; i++) { 551 | var last = i === keyparts.length - 1; 552 | var keypart = keyparts[i]; 553 | var indexable = isIndexable(doc); 554 | if (!indexable) { 555 | if (options.noCreate) return undefined; 556 | var e = MinimongoError$1("cannot use the part '" + keypart + "' to traverse " + doc); 557 | e.setPropertyError = true; 558 | throw e; 559 | } 560 | if (doc instanceof Array) { 561 | if (options.forbidArray) return null; 562 | if (keypart === '$') { 563 | if (usedArrayIndex) throw MinimongoError$1("Too many positional (i.e. '$') elements"); 564 | if (!options.arrayIndices || !options.arrayIndices.length) { 565 | throw MinimongoError$1("The positional operator did not find the " + "match needed from the query"); 566 | } 567 | keypart = options.arrayIndices[0]; 568 | usedArrayIndex = true; 569 | } else if (isNumericKey(keypart)) { 570 | keypart = parseInt(keypart); 571 | } else { 572 | if (options.noCreate) return undefined; 573 | throw MinimongoError$1("can't append to array using string field name [" + keypart + "]"); 574 | } 575 | if (last) 576 | // handle 'a.01' 577 | keyparts[i] = keypart; 578 | if (options.noCreate && keypart >= doc.length) return undefined; 579 | while (doc.length < keypart) { 580 | doc.push(null); 581 | }if (!last) { 582 | if (doc.length === keypart) doc.push({});else if (_typeof(doc[keypart]) !== "object") throw MinimongoError$1("can't modify field '" + keyparts[i + 1] + "' of list value " + JSON.stringify(doc[keypart])); 583 | } 584 | } else { 585 | assertIsValidFieldName(keypart); 586 | if (!(keypart in doc)) { 587 | if (options.noCreate) return undefined; 588 | if (!last) doc[keypart] = {}; 589 | } 590 | } 591 | 592 | if (last) return doc; 593 | doc = doc[keypart]; 594 | } 595 | 596 | // notreached 597 | }; 598 | 599 | var NO_CREATE_MODIFIERS = { 600 | $unset: true, 601 | $pop: true, 602 | $rename: true, 603 | $pull: true, 604 | $pullAll: true 605 | }; 606 | 607 | var MODIFIERS = { 608 | $currentDate: function $currentDate(target, field, arg) { 609 | if ((typeof arg === 'undefined' ? 'undefined' : _typeof(arg)) === "object" && arg.hasOwnProperty("$type")) { 610 | if (arg.$type !== "date") { 611 | throw MinimongoError$1("Minimongo does currently only support the date type " + "in $currentDate modifiers", { field: field }); 612 | } 613 | } else if (arg !== true) { 614 | throw MinimongoError$1("Invalid $currentDate modifier", { field: field }); 615 | } 616 | target[field] = new Date(); 617 | }, 618 | $min: function $min(target, field, arg) { 619 | if (typeof arg !== "number") { 620 | throw MinimongoError$1("Modifier $min allowed for numbers only", { field: field }); 621 | } 622 | if (field in target) { 623 | if (typeof target[field] !== "number") { 624 | throw MinimongoError$1("Cannot apply $min modifier to non-number", { field: field }); 625 | } 626 | if (target[field] > arg) { 627 | target[field] = arg; 628 | } 629 | } else { 630 | target[field] = arg; 631 | } 632 | }, 633 | $max: function $max(target, field, arg) { 634 | if (typeof arg !== "number") { 635 | throw MinimongoError$1("Modifier $max allowed for numbers only", { field: field }); 636 | } 637 | if (field in target) { 638 | if (typeof target[field] !== "number") { 639 | throw MinimongoError$1("Cannot apply $max modifier to non-number", { field: field }); 640 | } 641 | if (target[field] < arg) { 642 | target[field] = arg; 643 | } 644 | } else { 645 | target[field] = arg; 646 | } 647 | }, 648 | $inc: function $inc(target, field, arg) { 649 | if (typeof arg !== "number") throw MinimongoError$1("Modifier $inc allowed for numbers only", { field: field }); 650 | if (field in target) { 651 | if (typeof target[field] !== "number") throw MinimongoError$1("Cannot apply $inc modifier to non-number", { field: field }); 652 | target[field] += arg; 653 | } else { 654 | target[field] = arg; 655 | } 656 | }, 657 | $set: function $set(target, field, arg) { 658 | if (!_.isObject(target)) { 659 | // not an array or an object 660 | var e = MinimongoError$1("Cannot set property on non-object field", { field: field }); 661 | e.setPropertyError = true; 662 | throw e; 663 | } 664 | if (target === null) { 665 | var e = MinimongoError$1("Cannot set property on null", { field: field }); 666 | e.setPropertyError = true; 667 | throw e; 668 | } 669 | assertHasValidFieldNames(arg); 670 | target[field] = arg; 671 | }, 672 | $setOnInsert: function $setOnInsert(target, field, arg) { 673 | // converted to `$set` in `_modify` 674 | }, 675 | $unset: function $unset(target, field, arg) { 676 | if (target !== undefined) { 677 | if (target instanceof Array) { 678 | if (field in target) target[field] = null; 679 | } else delete target[field]; 680 | } 681 | }, 682 | $push: function $push(target, field, arg) { 683 | if (target[field] === undefined) target[field] = []; 684 | if (!(target[field] instanceof Array)) throw MinimongoError$1("Cannot apply $push modifier to non-array", { field: field }); 685 | 686 | if (!(arg && arg.$each)) { 687 | // Simple mode: not $each 688 | assertHasValidFieldNames(arg); 689 | target[field].push(arg); 690 | return; 691 | } 692 | 693 | // Fancy mode: $each (and maybe $slice and $sort and $position) 694 | var toPush = arg.$each; 695 | if (!(toPush instanceof Array)) throw MinimongoError$1("$each must be an array", { field: field }); 696 | assertHasValidFieldNames(toPush); 697 | 698 | // Parse $position 699 | var position = undefined; 700 | if ('$position' in arg) { 701 | if (typeof arg.$position !== "number") throw MinimongoError$1("$position must be a numeric value", { field: field }); 702 | // XXX should check to make sure integer 703 | if (arg.$position < 0) throw MinimongoError$1("$position in $push must be zero or positive", { field: field }); 704 | position = arg.$position; 705 | } 706 | 707 | // Parse $slice. 708 | var slice = undefined; 709 | if ('$slice' in arg) { 710 | if (typeof arg.$slice !== "number") throw MinimongoError$1("$slice must be a numeric value", { field: field }); 711 | // XXX should check to make sure integer 712 | if (arg.$slice > 0) throw MinimongoError$1("$slice in $push must be zero or negative", { field: field }); 713 | slice = arg.$slice; 714 | } 715 | 716 | // Parse $sort. 717 | var sortFunction = undefined; 718 | if (arg.$sort) { 719 | if (slice === undefined) throw MinimongoError$1("$sort requires $slice to be present", { field: field }); 720 | // XXX this allows us to use a $sort whose value is an array, but that's 721 | // actually an extension of the Node driver, so it won't work 722 | // server-side. Could be confusing! 723 | // XXX is it correct that we don't do geo-stuff here? 724 | sortFunction = new Minimongo.Sorter(arg.$sort).getComparator(); 725 | for (var i = 0; i < toPush.length; i++) { 726 | if (_f._type(toPush[i]) !== 3) { 727 | throw MinimongoError$1("$push like modifiers using $sort " + "require all elements to be objects", { field: field }); 728 | } 729 | } 730 | } 731 | 732 | // Actually push. 733 | if (position === undefined) { 734 | for (var j = 0; j < toPush.length; j++) { 735 | target[field].push(toPush[j]); 736 | } 737 | } else { 738 | var spliceArguments = [position, 0]; 739 | for (var j = 0; j < toPush.length; j++) { 740 | spliceArguments.push(toPush[j]); 741 | }Array.prototype.splice.apply(target[field], spliceArguments); 742 | } 743 | 744 | // Actually sort. 745 | if (sortFunction) target[field].sort(sortFunction); 746 | 747 | // Actually slice. 748 | if (slice !== undefined) { 749 | if (slice === 0) target[field] = []; // differs from Array.slice! 750 | else target[field] = target[field].slice(slice); 751 | } 752 | }, 753 | $pushAll: function $pushAll(target, field, arg) { 754 | if (!((typeof arg === 'undefined' ? 'undefined' : _typeof(arg)) === "object" && arg instanceof Array)) throw MinimongoError$1("Modifier $pushAll/pullAll allowed for arrays only"); 755 | assertHasValidFieldNames(arg); 756 | var x = target[field]; 757 | if (x === undefined) target[field] = arg;else if (!(x instanceof Array)) throw MinimongoError$1("Cannot apply $pushAll modifier to non-array", { field: field });else { 758 | for (var i = 0; i < arg.length; i++) { 759 | x.push(arg[i]); 760 | } 761 | } 762 | }, 763 | $addToSet: function $addToSet(target, field, arg) { 764 | var isEach = false; 765 | if ((typeof arg === 'undefined' ? 'undefined' : _typeof(arg)) === "object") { 766 | //check if first key is '$each' 767 | var _keys = Object.keys(arg); 768 | if (_keys[0] === "$each") { 769 | isEach = true; 770 | } 771 | } 772 | var values = isEach ? arg["$each"] : [arg]; 773 | assertHasValidFieldNames(values); 774 | var x = target[field]; 775 | if (x === undefined) target[field] = values;else if (!(x instanceof Array)) throw MinimongoError$1("Cannot apply $addToSet modifier to non-array", { field: field });else { 776 | _.each(values, function (value) { 777 | for (var i = 0; i < x.length; i++) { 778 | if (_f._equal(value, x[i])) return; 779 | }x.push(value); 780 | }); 781 | } 782 | }, 783 | $pop: function $pop(target, field, arg) { 784 | if (target === undefined) return; 785 | var x = target[field]; 786 | if (x === undefined) return;else if (!(x instanceof Array)) throw MinimongoError$1("Cannot apply $pop modifier to non-array", { field: field });else { 787 | if (typeof arg === 'number' && arg < 0) x.splice(0, 1);else x.pop(); 788 | } 789 | }, 790 | $pull: function $pull(target, field, arg) { 791 | if (target === undefined) return; 792 | var x = target[field]; 793 | if (x === undefined) return;else if (!(x instanceof Array)) throw MinimongoError$1("Cannot apply $pull/pullAll modifier to non-array", { field: field });else { 794 | var out = []; 795 | if (arg != null && (typeof arg === 'undefined' ? 'undefined' : _typeof(arg)) === "object" && !(arg instanceof Array)) { 796 | // XXX would be much nicer to compile this once, rather than 797 | // for each document we modify.. but usually we're not 798 | // modifying that many documents, so we'll let it slide for 799 | // now 800 | 801 | // XXX Minimongo.Matcher isn't up for the job, because we need 802 | // to permit stuff like {$pull: {a: {$gt: 4}}}.. something 803 | // like {$gt: 4} is not normally a complete selector. 804 | // same issue as $elemMatch possibly? 805 | var matcher = new Minimongo.Matcher(arg); 806 | for (var i = 0; i < x.length; i++) { 807 | if (!matcher.documentMatches(x[i]).result) out.push(x[i]); 808 | } 809 | } else { 810 | for (var i = 0; i < x.length; i++) { 811 | if (!_f._equal(x[i], arg)) out.push(x[i]); 812 | } 813 | } 814 | target[field] = out; 815 | } 816 | }, 817 | $pullAll: function $pullAll(target, field, arg) { 818 | if (!((typeof arg === 'undefined' ? 'undefined' : _typeof(arg)) === "object" && arg instanceof Array)) throw MinimongoError$1("Modifier $pushAll/pullAll allowed for arrays only", { field: field }); 819 | if (target === undefined) return; 820 | var x = target[field]; 821 | if (x === undefined) return;else if (!(x instanceof Array)) throw MinimongoError$1("Cannot apply $pull/pullAll modifier to non-array", { field: field });else { 822 | var out = []; 823 | for (var i = 0; i < x.length; i++) { 824 | var exclude = false; 825 | for (var j = 0; j < arg.length; j++) { 826 | if (_f._equal(x[i], arg[j])) { 827 | exclude = true; 828 | break; 829 | } 830 | } 831 | if (!exclude) out.push(x[i]); 832 | } 833 | target[field] = out; 834 | } 835 | }, 836 | $rename: function $rename(target, field, arg, keypath, doc) { 837 | if (keypath === arg) 838 | // no idea why mongo has this restriction.. 839 | throw MinimongoError$1("$rename source must differ from target", { field: field }); 840 | if (target === null) throw MinimongoError$1("$rename source field invalid", { field: field }); 841 | if (typeof arg !== "string") throw MinimongoError$1("$rename target must be a string", { field: field }); 842 | if (arg.indexOf('\0') > -1) { 843 | // Null bytes are not allowed in Mongo field names 844 | // https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names 845 | throw MinimongoError$1("The 'to' field for $rename cannot contain an embedded null byte", { field: field }); 846 | } 847 | if (target === undefined) return; 848 | var v = target[field]; 849 | delete target[field]; 850 | 851 | var keyparts = arg.split('.'); 852 | var target2 = findModTarget(doc, keyparts, { forbidArray: true }); 853 | if (target2 === null) throw MinimongoError$1("$rename target field invalid", { field: field }); 854 | var field2 = keyparts.pop(); 855 | target2[field2] = v; 856 | }, 857 | $bit: function $bit(target, field, arg) { 858 | // XXX mongo only supports $bit on integers, and we only support 859 | // native javascript numbers (doubles) so far, so we can't support $bit 860 | throw MinimongoError$1("$bit is not supported", { field: field }); 861 | } 862 | }; 863 | 864 | module.exports = modify; 865 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "modifyjs", 3 | "version": "0.3.1", 4 | "description": "Modify your objects with a mongo syntax.", 5 | "main": "dist/bundle.js", 6 | "scripts": { 7 | "test": "BABEL_ENV=dev jest src/", 8 | "build": "BABEL_ENV=rollup rollup src/modify.js --config rollup.config.prod.js", 9 | "browserify": "npm run build ; browserify dist/modify.js -o dist/bundle.js --full-paths", 10 | "weight": "npm run browserify ; cat dist/bundle.js | uglifyjs --compress --mangle | discify --open --full-paths" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/lgandecki/modifyjs.git" 15 | }, 16 | "keywords": [ 17 | "mongo", 18 | "update", 19 | "modify", 20 | "minimongo" 21 | ], 22 | "author": "Łukasz Gandecki", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/lgandecki/modifyjs/issues" 26 | }, 27 | "homepage": "https://github.com/lgandecki/modifyjs#readme", 28 | "devDependencies": { 29 | "babel-cli": "^6.24.1", 30 | "babel-jest": "^19.0.0", 31 | "babel-plugin-external-helpers": "^6.22.0", 32 | "babel-preset-es2015": "^6.24.1", 33 | "babel-preset-latest": "^6.24.1", 34 | "babel-preset-stage-2": "^6.24.1", 35 | "browserify": "^14.3.0", 36 | "disc": "^1.3.2", 37 | "immutability-helper": "^2.2.0", 38 | "jest": "^19.0.2", 39 | "rollup": "^0.41.6", 40 | "rollup-plugin-babel": "^2.7.1", 41 | "rollup-plugin-node-resolve": "^3.0.0", 42 | "rollup-plugin-visualizer": "^0.2.1", 43 | "uglifyjs": "^2.4.10" 44 | }, 45 | "dependencies": { 46 | "clone": "^2.1.1", 47 | "deep-equal": "^1.0.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /rollup.config.prod.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import babel from 'rollup-plugin-babel'; 3 | import Visualizer from 'rollup-plugin-visualizer'; 4 | 5 | export default { 6 | entry: 'src/modify.js', 7 | format: 'cjs', 8 | dest: 'dist/bundle.js', 9 | plugins: [ 10 | resolve({ 11 | // pass custom options to the resolve plugin 12 | customResolveOptions: { 13 | moduleDirectory: 'node_modules' 14 | } 15 | }), 16 | babel({ 17 | exclude: 'node_modules/**' 18 | }), 19 | Visualizer() 20 | ], 21 | external: ['underscore', 'clone', 'deep-equal'] 22 | }; -------------------------------------------------------------------------------- /src/ModifyJsError.js: -------------------------------------------------------------------------------- 1 | export default function (message, options={}) { 2 | if (typeof message === "string" && options.field) { 3 | message += ` for field '${options.field}'`; 4 | } 5 | 6 | var e = new Error(message); 7 | e.name = "ModifyJsError"; 8 | return e; 9 | }; -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import {default as libIsArray} from './lib/isArray'; 2 | import each from './lib/each'; 3 | import isBinary from './lib/isBinary' 4 | import isPlainObject from './lib/isPlainObject' 5 | 6 | const _ = {isArray: libIsArray, each}; 7 | // Like _.isArray, but doesn't regard polyfilled Uint8Arrays on old browsers as 8 | // arrays. 9 | // XXX maybe this should be EJSON.isArray 10 | export const isArray = function (x) { 11 | return _.isArray(x) && !isBinary(x); 12 | }; 13 | 14 | 15 | export const isIndexable = function (x) { 16 | return isArray(x) || isPlainObject(x); 17 | }; 18 | 19 | // Returns true if this is an object with at least one key and all keys begin 20 | // with $. Unless inconsistentOK is set, throws if some keys begin with $ and 21 | // others don't. 22 | export const isOperatorObject = function (valueSelector, inconsistentOK) { 23 | if (!isPlainObject(valueSelector)) 24 | return false; 25 | 26 | var theseAreOperators = undefined; 27 | _.each(valueSelector, function (value, selKey) { 28 | var thisIsOperator = selKey.substr(0, 1) === '$'; 29 | if (theseAreOperators === undefined) { 30 | theseAreOperators = thisIsOperator; 31 | } else if (theseAreOperators !== thisIsOperator) { 32 | if (!inconsistentOK) 33 | throw new Error("Inconsistent operator: " + 34 | JSON.stringify(valueSelector)); 35 | theseAreOperators = false; 36 | } 37 | }); 38 | return !!theseAreOperators; // {} has no operators 39 | }; 40 | 41 | // string can be converted to integer 42 | export const isNumericKey = function (s) { 43 | return /^[0-9]+$/.test(s); 44 | }; -------------------------------------------------------------------------------- /src/lib/each.js: -------------------------------------------------------------------------------- 1 | export default (objectToIterate, cb) => { 2 | Object.keys(objectToIterate).forEach((key) => { 3 | cb(objectToIterate[key], key); 4 | }) 5 | } -------------------------------------------------------------------------------- /src/lib/every.js: -------------------------------------------------------------------------------- 1 | export default (arrayToIterate, cb) => { 2 | return arrayToIterate.every((elem) => ((cb && cb(elem)) || elem)); 3 | } -------------------------------------------------------------------------------- /src/lib/has.js: -------------------------------------------------------------------------------- 1 | export default (objectWithKeys, key) => { 2 | return objectWithKeys.hasOwnProperty(key); 3 | } -------------------------------------------------------------------------------- /src/lib/isArray.js: -------------------------------------------------------------------------------- 1 | export default (variableToCheck) => { 2 | return Array.isArray(variableToCheck); 3 | } -------------------------------------------------------------------------------- /src/lib/isBinary.js: -------------------------------------------------------------------------------- 1 | export default (variableToCheck) => { 2 | return !!((typeof Uint8Array !== 'undefined' && variableToCheck instanceof Uint8Array) || 3 | (variableToCheck && variableToCheck.$Uint8ArrayPolyfill)); 4 | }; 5 | -------------------------------------------------------------------------------- /src/lib/isObject.js: -------------------------------------------------------------------------------- 1 | export default (variableToCheck) => { 2 | return (typeof variableToCheck === "object") && (variableToCheck !== null) 3 | } -------------------------------------------------------------------------------- /src/lib/isPlainObject.js: -------------------------------------------------------------------------------- 1 | import {isArray} from './../helpers'; 2 | import isBinary from './../lib/isBinary'; 3 | 4 | export default (variableToCheck) => { 5 | if (!variableToCheck) 6 | return false; 7 | if (typeof variableToCheck === "number") 8 | return false; 9 | if (typeof variableToCheck === "string") 10 | return false; 11 | if (typeof variableToCheck === "boolean") 12 | return false; 13 | if (isArray(variableToCheck)) 14 | return false; 15 | if (variableToCheck === null) 16 | return false; 17 | if (variableToCheck instanceof RegExp) 18 | // note that typeof(/x/) === "object" 19 | return false; 20 | if (typeof variableToCheck === "function") 21 | return false; 22 | if (variableToCheck instanceof Date) 23 | return false; 24 | if (isBinary(variableToCheck)) 25 | return false; 26 | 27 | return true; // object 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/keys.js: -------------------------------------------------------------------------------- 1 | export default (objectWithKeys) => { 2 | return Object.keys(objectWithKeys) 3 | } -------------------------------------------------------------------------------- /src/modify.js: -------------------------------------------------------------------------------- 1 | import clone from 'clone'; 2 | import equal from 'deep-equal'; 3 | import isObject from './lib/isObject'; 4 | import every from './lib/every'; 5 | import has from './lib/has'; 6 | import keys from './lib/keys'; 7 | import each from './lib/each.js'; 8 | import isPlainObject from './lib/isPlainObject'; 9 | import { isOperatorObject, isIndexable, isNumericKey } from './helpers'; 10 | import ModifyJsError from './ModifyJsError'; 11 | 12 | const _ = {all: every, each, keys, has, isObject}; 13 | // XXX need a strategy for passing the binding of $ into this 14 | // function, from the compiled selector 15 | // 16 | // maybe just {key.up.to.just.before.dollarsign: array_index} 17 | // 18 | // XXX atomicity: if one modification fails, do we roll back the whole 19 | // change? 20 | // 21 | // options: 22 | // - isInsert is set when _modify is being called to compute the document to 23 | // insert as part of an upsert operation. We use this primarily to figure 24 | // out when to set the fields in $setOnInsert, if present. 25 | export default function(doc, mod, options) { 26 | if (options && options.each) { 27 | return 28 | } 29 | return _modify(doc, mod, {...options, returnInsteadOfReplacing: true}) 30 | } 31 | 32 | 33 | const _modify = function (doc, mod, options) { 34 | options = options || {}; 35 | if (!isPlainObject(mod)) 36 | throw ModifyJsError("Modifier must be an object"); 37 | 38 | // Make sure the caller can't mutate our data structures. 39 | mod = clone(mod); 40 | 41 | var isModifier = isOperatorObject(mod); 42 | 43 | var newDoc; 44 | 45 | if (!isModifier) { 46 | // replace the whole document 47 | newDoc = mod; 48 | } else { 49 | // apply modifiers to the doc. 50 | newDoc = clone(doc); 51 | _.each(mod, function (operand, op) { 52 | var modFunc = MODIFIERS[op]; 53 | // Treat $setOnInsert as $set if this is an insert. 54 | if (!modFunc) 55 | throw ModifyJsError("Invalid modifier specified " + op); 56 | _.each(operand, function (arg, keypath) { 57 | if (keypath === '') { 58 | throw ModifyJsError("An empty update path is not valid."); 59 | } 60 | 61 | var keyparts = keypath.split('.'); 62 | if (!_.all(keyparts)) { 63 | throw ModifyJsError( 64 | "The update path '" + keypath + 65 | "' contains an empty field name, which is not allowed."); 66 | } 67 | 68 | var target = findModTarget(newDoc, keyparts, { 69 | noCreate: NO_CREATE_MODIFIERS[op], 70 | forbidArray: (op === "$rename"), 71 | arrayIndices: options.arrayIndices 72 | }); 73 | var field = keyparts.pop(); 74 | modFunc(target, field, arg, keypath, newDoc); 75 | }); 76 | }); 77 | } 78 | 79 | if (options.returnInsteadOfReplacing) { 80 | return newDoc; 81 | } else { 82 | // move new document into place. 83 | _.each(_.keys(doc), function (k) { 84 | // Note: this used to be for (var k in doc) however, this does not 85 | // work right in Opera. Deleting from a doc while iterating over it 86 | // would sometimes cause opera to skip some keys. 87 | if (k !== '_id') 88 | delete doc[k]; 89 | }); 90 | _.each(newDoc, function (v, k) { 91 | doc[k] = v; 92 | }); 93 | } 94 | }; 95 | 96 | // for a.b.c.2.d.e, keyparts should be ['a', 'b', 'c', '2', 'd', 'e'], 97 | // and then you would operate on the 'e' property of the returned 98 | // object. 99 | // 100 | // if options.noCreate is falsey, creates intermediate levels of 101 | // structure as necessary, like mkdir -p (and raises an exception if 102 | // that would mean giving a non-numeric property to an array.) if 103 | // options.noCreate is true, return undefined instead. 104 | // 105 | // may modify the last element of keyparts to signal to the caller that it needs 106 | // to use a different value to index into the returned object (for example, 107 | // ['a', '01'] -> ['a', 1]). 108 | // 109 | // if forbidArray is true, return null if the keypath goes through an array. 110 | // 111 | // if options.arrayIndices is set, use its first element for the (first) '$' in 112 | // the path. 113 | var findModTarget = function (doc, keyparts, options) { 114 | options = options || {}; 115 | var usedArrayIndex = false; 116 | for (var i = 0; i < keyparts.length; i++) { 117 | var last = (i === keyparts.length - 1); 118 | var keypart = keyparts[i]; 119 | var indexable = isIndexable(doc); 120 | if (!indexable) { 121 | if (options.noCreate) 122 | return undefined; 123 | var e = ModifyJsError( 124 | "cannot use the part '" + keypart + "' to traverse " + doc); 125 | e.setPropertyError = true; 126 | throw e; 127 | } 128 | if (doc instanceof Array) { 129 | if (options.forbidArray) 130 | return null; 131 | if (keypart === '$') { 132 | if (usedArrayIndex) 133 | throw ModifyJsError("Too many positional (i.e. '$') elements"); 134 | if (!options.arrayIndices || !options.arrayIndices.length) { 135 | throw ModifyJsError("The positional operator did not find the " + 136 | "match needed from the query"); 137 | } 138 | keypart = options.arrayIndices[0]; 139 | usedArrayIndex = true; 140 | } else if (isNumericKey(keypart)) { 141 | keypart = parseInt(keypart); 142 | } else { 143 | if (options.noCreate) 144 | return undefined; 145 | throw ModifyJsError( 146 | "can't append to array using string field name [" 147 | + keypart + "]"); 148 | } 149 | if (last) 150 | // handle 'a.01' 151 | keyparts[i] = keypart; 152 | if (options.noCreate && keypart >= doc.length) 153 | return undefined; 154 | while (doc.length < keypart) 155 | doc.push(null); 156 | if (!last) { 157 | if (doc.length === keypart) 158 | doc.push({}); 159 | else if (typeof doc[keypart] !== "object") 160 | throw ModifyJsError("can't modify field '" + keyparts[i + 1] + 161 | "' of list value " + JSON.stringify(doc[keypart])); 162 | } 163 | } else { 164 | if (!(keypart in doc)) { 165 | if (options.noCreate) 166 | return undefined; 167 | if (!last) 168 | doc[keypart] = {}; 169 | } 170 | } 171 | 172 | if (last) 173 | return doc; 174 | doc = doc[keypart]; 175 | } 176 | 177 | }; 178 | 179 | var NO_CREATE_MODIFIERS = { 180 | $unset: true, 181 | $pop: true, 182 | $rename: true, 183 | $pull: true, 184 | $pullAll: true 185 | }; 186 | 187 | var MODIFIERS = { 188 | $currentDate: function (target, field, arg) { 189 | if (typeof arg === "object" && arg.hasOwnProperty("$type")) { 190 | if (arg.$type !== "date") { 191 | throw ModifyJsError( 192 | "Minimongo does currently only support the date type " + 193 | "in $currentDate modifiers", 194 | { field }); 195 | } 196 | } else if (arg !== true) { 197 | throw ModifyJsError("Invalid $currentDate modifier", { field }); 198 | } 199 | target[field] = new Date(); 200 | }, 201 | $min: function (target, field, arg) { 202 | if (typeof arg !== "number") { 203 | throw ModifyJsError("Modifier $min allowed for numbers only", { field }); 204 | } 205 | if (field in target) { 206 | if (typeof target[field] !== "number") { 207 | throw ModifyJsError( 208 | "Cannot apply $min modifier to non-number", { field }); 209 | } 210 | if (target[field] > arg) { 211 | target[field] = arg; 212 | } 213 | } else { 214 | target[field] = arg; 215 | } 216 | }, 217 | $max: function (target, field, arg) { 218 | if (typeof arg !== "number") { 219 | throw ModifyJsError("Modifier $max allowed for numbers only", { field }); 220 | } 221 | if (field in target) { 222 | if (typeof target[field] !== "number") { 223 | throw ModifyJsError( 224 | "Cannot apply $max modifier to non-number", { field }); 225 | } 226 | if (target[field] < arg) { 227 | target[field] = arg; 228 | } 229 | } else { 230 | target[field] = arg; 231 | } 232 | }, 233 | $inc: function (target, field, arg) { 234 | if (typeof arg !== "number") 235 | throw ModifyJsError("Modifier $inc allowed for numbers only", { field }); 236 | if (field in target) { 237 | if (typeof target[field] !== "number") 238 | throw ModifyJsError( 239 | "Cannot apply $inc modifier to non-number", { field }); 240 | target[field] += arg; 241 | } else { 242 | target[field] = arg; 243 | } 244 | }, 245 | $set: function (target, field, arg) { 246 | if (!_.isObject(target)) { // not an array or an object 247 | var e = ModifyJsError( 248 | "Cannot set property on non-object field", { field }); 249 | e.setPropertyError = true; 250 | throw e; 251 | } 252 | if (target === null) { 253 | var e = ModifyJsError("Cannot set property on null", { field }); 254 | e.setPropertyError = true; 255 | throw e; 256 | } 257 | target[field] = arg; 258 | }, 259 | $setOnInsert: function (target, field, arg) { 260 | // converted to `$set` in `_modify` 261 | }, 262 | $unset: function (target, field, arg) { 263 | if (target !== undefined) { 264 | if (target instanceof Array) { 265 | if (field in target) 266 | target[field] = null; 267 | } else 268 | delete target[field]; 269 | } 270 | }, 271 | $push: function (target, field, arg) { 272 | if (target[field] === undefined) 273 | target[field] = []; 274 | if (!(target[field] instanceof Array)) 275 | throw ModifyJsError( 276 | "Cannot apply $push modifier to non-array", { field }); 277 | 278 | if (!(arg && arg.$each)) { 279 | // Simple mode: not $each 280 | target[field].push(arg); 281 | return; 282 | } 283 | 284 | // Fancy mode: $each (and maybe $slice and $sort and $position) 285 | var toPush = arg.$each; 286 | if (!(toPush instanceof Array)) 287 | throw ModifyJsError("$each must be an array", { field }); 288 | 289 | // Parse $position 290 | var position = undefined; 291 | if ('$position' in arg) { 292 | if (typeof arg.$position !== "number") 293 | throw ModifyJsError("$position must be a numeric value", { field }); 294 | // XXX should check to make sure integer 295 | if (arg.$position < 0) 296 | throw ModifyJsError( 297 | "$position in $push must be zero or positive", { field }); 298 | position = arg.$position; 299 | } 300 | 301 | // Parse $slice. 302 | var slice = undefined; 303 | if ('$slice' in arg) { 304 | if (typeof arg.$slice !== "number") 305 | throw ModifyJsError("$slice must be a numeric value", { field }); 306 | // XXX should check to make sure integer 307 | if (arg.$slice > 0) 308 | throw ModifyJsError( 309 | "$slice in $push must be zero or negative", { field }); 310 | slice = arg.$slice; 311 | } 312 | 313 | // Parse $sort. 314 | var sortFunction = undefined; 315 | if (arg.$sort) { 316 | throw ModifyJsError("$sort in $push not implemented yet"); 317 | // if (slice === undefined) 318 | // throw ModifyJsError("$sort requires $slice to be present", { field }); 319 | // // XXX this allows us to use a $sort whose value is an array, but that's 320 | // // actually an extension of the Node driver, so it won't work 321 | // // server-side. Could be confusing! 322 | // // XXX is it correct that we don't do geo-stuff here? 323 | // sortFunction = new Minimongo.Sorter(arg.$sort).getComparator(); 324 | // for (var i = 0; i < toPush.length; i++) { 325 | // if (_f._type(toPush[i]) !== 3) { 326 | // throw ModifyJsError("$push like modifiers using $sort " + 327 | // "require all elements to be objects", { field }); 328 | // } 329 | // } 330 | } 331 | 332 | // Actually push. 333 | if (position === undefined) { 334 | for (var j = 0; j < toPush.length; j++) 335 | target[field].push(toPush[j]); 336 | } else { 337 | var spliceArguments = [position, 0]; 338 | for (var j = 0; j < toPush.length; j++) 339 | spliceArguments.push(toPush[j]); 340 | Array.prototype.splice.apply(target[field], spliceArguments); 341 | } 342 | 343 | // Actually sort. 344 | if (sortFunction) 345 | target[field].sort(sortFunction); 346 | 347 | // Actually slice. 348 | if (slice !== undefined) { 349 | if (slice === 0) 350 | target[field] = []; // differs from Array.slice! 351 | else 352 | target[field] = target[field].slice(slice); 353 | } 354 | }, 355 | $pushAll: function (target, field, arg) { 356 | if (!(typeof arg === "object" && arg instanceof Array)) 357 | throw ModifyJsError("Modifier $pushAll/pullAll allowed for arrays only"); 358 | var x = target[field]; 359 | if (x === undefined) 360 | target[field] = arg; 361 | else if (!(x instanceof Array)) 362 | throw ModifyJsError( 363 | "Cannot apply $pushAll modifier to non-array", { field }); 364 | else { 365 | for (var i = 0; i < arg.length; i++) 366 | x.push(arg[i]); 367 | } 368 | }, 369 | $addToSet: function (target, field, arg) { 370 | var isEach = false; 371 | if (typeof arg === "object") { 372 | //check if first key is '$each' 373 | const keys = Object.keys(arg); 374 | if (keys[0] === "$each"){ 375 | isEach = true; 376 | } 377 | } 378 | var values = isEach ? arg["$each"] : [arg]; 379 | var x = target[field]; 380 | if (x === undefined) 381 | target[field] = values; 382 | else if (!(x instanceof Array)) 383 | throw ModifyJsError( 384 | "Cannot apply $addToSet modifier to non-array", { field }); 385 | else { 386 | _.each(values, function (value) { 387 | for (var i = 0; i < x.length; i++) 388 | if (equal(value, x[i])) 389 | return; 390 | x.push(value); 391 | }); 392 | } 393 | }, 394 | $pop: function (target, field, arg) { 395 | if (target === undefined) 396 | return; 397 | var x = target[field]; 398 | if (x === undefined) 399 | return; 400 | else if (!(x instanceof Array)) 401 | throw ModifyJsError( 402 | "Cannot apply $pop modifier to non-array", { field }); 403 | else { 404 | if (typeof arg === 'number' && arg < 0) 405 | x.splice(0, 1); 406 | else 407 | x.pop(); 408 | } 409 | }, 410 | $pull: function (target, field, arg) { 411 | if (target === undefined) 412 | return; 413 | var x = target[field]; 414 | if (x === undefined) 415 | return; 416 | else if (!(x instanceof Array)) 417 | throw ModifyJsError( 418 | "Cannot apply $pull/pullAll modifier to non-array", { field }); 419 | else { 420 | throw ModifyJsError("$pull not implemented yet") 421 | // var out = []; 422 | // if (arg != null && typeof arg === "object" && !(arg instanceof Array)) { 423 | // // XXX would be much nicer to compile this once, rather than 424 | // // for each document we modify.. but usually we're not 425 | // // modifying that many documents, so we'll let it slide for 426 | // // now 427 | // 428 | // // XXX Minimongo.Matcher isn't up for the job, because we need 429 | // // to permit stuff like {$pull: {a: {$gt: 4}}}.. something 430 | // // like {$gt: 4} is not normally a complete selector. 431 | // // same issue as $elemMatch possibly? 432 | // var matcher = new Minimongo.Matcher(arg); 433 | // for (var i = 0; i < x.length; i++) 434 | // if (!matcher.documentMatches(x[i]).result) 435 | // out.push(x[i]); 436 | // } else { 437 | // for (var i = 0; i < x.length; i++) 438 | // if (!_f._equal(x[i], arg)) 439 | // out.push(x[i]); 440 | // } 441 | // target[field] = out; 442 | } 443 | }, 444 | $pullAll: function (target, field, arg) { 445 | if (!(typeof arg === "object" && arg instanceof Array)) 446 | throw ModifyJsError( 447 | "Modifier $pushAll/pullAll allowed for arrays only", { field }); 448 | if (target === undefined) 449 | return; 450 | var x = target[field]; 451 | if (x === undefined) 452 | return; 453 | else if (!(x instanceof Array)) 454 | throw ModifyJsError( 455 | "Cannot apply $pull/pullAll modifier to non-array", { field }); 456 | else { 457 | var out = []; 458 | for (var i = 0; i < x.length; i++) { 459 | var exclude = false; 460 | for (var j = 0; j < arg.length; j++) { 461 | if (equal(x[i], arg[j])) { 462 | exclude = true; 463 | break; 464 | } 465 | } 466 | if (!exclude) 467 | out.push(x[i]); 468 | } 469 | target[field] = out; 470 | } 471 | }, 472 | $rename: function (target, field, arg, keypath, doc) { 473 | if (keypath === arg) 474 | // no idea why mongo has this restriction.. 475 | throw ModifyJsError("$rename source must differ from target", { field }); 476 | if (target === null) 477 | throw ModifyJsError("$rename source field invalid", { field }); 478 | if (typeof arg !== "string") 479 | throw ModifyJsError("$rename target must be a string", { field }); 480 | if (arg.indexOf('\0') > -1) { 481 | // Null bytes are not allowed in Mongo field names 482 | // https://docs.mongodb.com/manual/reference/limits/#Restrictions-on-Field-Names 483 | throw ModifyJsError( 484 | "The 'to' field for $rename cannot contain an embedded null byte", 485 | { field }); 486 | } 487 | if (target === undefined) 488 | return; 489 | var v = target[field]; 490 | delete target[field]; 491 | 492 | var keyparts = arg.split('.'); 493 | var target2 = findModTarget(doc, keyparts, {forbidArray: true}); 494 | if (target2 === null) 495 | throw ModifyJsError("$rename target field invalid", { field }); 496 | var field2 = keyparts.pop(); 497 | target2[field2] = v; 498 | }, 499 | $bit: function (target, field, arg) { 500 | // XXX mongo only supports $bit on integers, and we only support 501 | // native javascript numbers (doubles) so far, so we can't support $bit 502 | throw ModifyJsError("$bit is not supported", { field }); 503 | } 504 | }; 505 | -------------------------------------------------------------------------------- /src/modify.test.js: -------------------------------------------------------------------------------- 1 | import update from 'immutability-helper'; 2 | import modify from './modify.js'; 3 | import each from './lib/each'; 4 | 5 | 6 | // Examples are taken from https://docs.mongodb.com/manual/reference/ 7 | describe("modify", function () { 8 | describe("$currentDate", () => { 9 | 10 | it('something', () => { 11 | 12 | const testObject = { 13 | 'first': 1, 14 | 'second': 2, 15 | 'third': 3 16 | }; 17 | 18 | const changedObject = {}; 19 | 20 | const expectedObject = { 21 | 'first': 2, 22 | 'second': 4, 23 | 'third': 6 24 | }; 25 | each(testObject, (value, key) => { 26 | changedObject[key] = value * 2; 27 | }) 28 | // 29 | // Object.keys(testObject).forEach(key => { 30 | // changedObject[key] = testObject[key] * 2; 31 | // }) 32 | expect(changedObject).toEqual(expectedObject); 33 | }) 34 | 35 | it.skip("sets a field with a currentDate", () => { 36 | const myObject = {existingItem: "here"}; 37 | 38 | const updatedObject = modify(myObject, {$currentDate: "lastModified"}); 39 | 40 | 41 | const expectedObject = {existingItem: "here", lastModified: new Date()}; // placeholder, obviously wrong 42 | expect(updatedObject).toEqual(expectedObject); 43 | }); 44 | }) 45 | describe("$min", () => { 46 | it("updates a field when the passed value is lower than an existing one", () => { 47 | const myObject = { _id: 1, highScore: 800, lowScore: 200 }; 48 | 49 | const updatedObject = modify(myObject, {$min: {lowScore: 150}}); 50 | 51 | const expectedObject = {...myObject, lowScore: 150}; 52 | expect(updatedObject).toEqual(expectedObject); 53 | }); 54 | it("doesn't update a field when the passed value is higher than an existing one", () => { 55 | const myObject = { _id: 1, highScore: 800, lowScore: 200 }; 56 | 57 | const updatedObject = modify(myObject, {$min: {lowScore: 250}}); 58 | 59 | const expectedObject = {...myObject}; 60 | expect(updatedObject).toEqual(expectedObject); 61 | }) 62 | }); 63 | describe("$max", () => { 64 | it("updates a field when the passed value is higher than an existing one", () => { 65 | const myObject = {_id: 1, highScore: 800, lowScore: 200}; 66 | 67 | const updatedObject = modify(myObject, {$max: {highScore: 950}}); 68 | 69 | const expectedObject = {...myObject, highScore: 950}; 70 | expect(updatedObject).toEqual(expectedObject); 71 | }); 72 | it("doesn't update a field when the passed value is lower than an existing one", () => { 73 | const myObject = { _id: 1, highScore: 950, lowScore: 200 }; 74 | 75 | const updatedObject = modify(myObject, {$max: {highScore: 870}}); 76 | 77 | const expectedObject = {...myObject}; 78 | expect(updatedObject).toEqual(expectedObject); 79 | }) 80 | }); 81 | describe("$inc", () => { 82 | it("can increment with positive and negative values at the same time", () => { 83 | const myObject = { 84 | _id: 1, 85 | sku: "abc123", 86 | quantity: 10, 87 | metrics: { 88 | orders: 2, 89 | ratings: 3.5 90 | } 91 | }; 92 | 93 | const updatedObject = modify(myObject, {$inc: {quantity: -2, "metrics.orders": 1}}); 94 | 95 | const expectedObject = update(myObject, { 96 | quantity: {$set: myObject.quantity - 2}, 97 | metrics: { 98 | orders: {$set: myObject.metrics.orders + 1} 99 | } 100 | }); 101 | 102 | expect(updatedObject).toEqual(expectedObject); 103 | }) 104 | }); 105 | describe("$set", () => { 106 | // https://docs.mongodb.com/manual/reference/operator/update/set/#set-top-level-fields 107 | it("sets top-level fields", () => { 108 | const myObject = { 109 | _id: 100, 110 | sku: "abc123", 111 | quantity: 250, 112 | instock: true, 113 | reorder: false, 114 | details: { model: "14Q2", make: "xyz" }, 115 | tags: [ "apparel", "clothing" ], 116 | ratings: [ { by: "ijk", rating: 4 } ] 117 | }; 118 | 119 | const updatedObject = modify(myObject, { $set: 120 | { 121 | quantity: 500, 122 | details: { model: "14Q3", make: "xyz" }, 123 | tags: [ "coats", "outerwear", "clothing" ] 124 | } 125 | }); 126 | 127 | const expectedObject = update(myObject, { 128 | quantity: {$set: 500}, 129 | details: {$set: {model: "14Q3", make: "xyz"}}, 130 | tags: {$set: [ "coats", "outerwear", "clothing" ]} 131 | }); 132 | 133 | expect(updatedObject).toEqual(expectedObject); 134 | }); 135 | // https://docs.mongodb.com/manual/reference/operator/update/set/#set-fields-in-embedded-documents 136 | it("sets fields in embedded documents", () => { 137 | const myObject = { 138 | _id: 100, 139 | sku: "abc123", 140 | quantity: 250, 141 | instock: true, 142 | reorder: false, 143 | details: { model: "14Q2", make: "xyz" }, 144 | tags: [ "apparel", "clothing" ], 145 | ratings: [ { by: "ijk", rating: 4 } ] 146 | }; 147 | 148 | const updatedObject = modify(myObject, {$set: { "details.make": "zzz"}}); 149 | 150 | const expectedObject = update(myObject, { 151 | details: { 152 | make: {$set: "zzz"} 153 | } 154 | }); 155 | 156 | expect(updatedObject).toEqual(expectedObject); 157 | }); 158 | // https://docs.mongodb.com/manual/reference/operator/update/set/#set-elements-in-arrays 159 | it("sets elements in arrays", () => { 160 | const myObject = { 161 | _id: 100, 162 | sku: "abc123", 163 | quantity: 250, 164 | instock: true, 165 | reorder: false, 166 | details: { model: "14Q2", make: "xyz" }, 167 | tags: [ "apparel", "clothing" ], 168 | ratings: [ { by: "ijk", rating: 4 } ] 169 | }; 170 | 171 | const updatedObject = modify(myObject, { $set: 172 | { 173 | "tags.1": "rain gear", 174 | "ratings.0.rating": 2 175 | } 176 | }); 177 | 178 | const expectedObject = update(myObject, { 179 | tags: { 180 | 1: {$set: "rain gear"} 181 | }, 182 | ratings: { 183 | 0: { 184 | rating: {$set: 2} 185 | } 186 | } 187 | }); 188 | 189 | expect(updatedObject).toEqual(expectedObject); 190 | }) 191 | }); 192 | // https://docs.mongodb.com/manual/reference/operator/update/unset/ 193 | describe("$unset", () => { 194 | it("deletes a particular field", () => { 195 | const myObject = {sku: "unknown", quantity: 2, instock: true}; 196 | 197 | const updatedObject = modify(myObject, {$unset: {quantity: "", instock: ""}}); 198 | 199 | const expectedObject = {...myObject}; 200 | delete expectedObject.quantity; 201 | delete expectedObject.instock; 202 | expect(updatedObject).toEqual(expectedObject); 203 | }) 204 | }); 205 | describe("$push", () => { 206 | // https://docs.mongodb.com/manual/reference/operator/update/push/#append-a-value-to-an-array 207 | it("appends a value to an array", () => { 208 | const myObject = {_id: 1, scores: [50]}; 209 | 210 | const updatedObject = modify(myObject, {$push: {scores: 89}}); 211 | 212 | const expectedObject = update(myObject, { 213 | scores: {$push: [89]} 214 | }); 215 | expect(updatedObject).toEqual(expectedObject); 216 | }); 217 | // https://docs.mongodb.com/manual/reference/operator/update/push/#append-multiple-values-to-an-array 218 | it("appends multiple values to an array", () => { 219 | const myObject = {name: "joe", scores: [50]}; 220 | 221 | const updatedObject = modify(myObject, {$push: {scores: {$each: [90, 92, 85]}}}); 222 | 223 | const expectedObject = update(myObject, { 224 | scores: {$push: [90, 92, 85]} 225 | }); 226 | 227 | expect(updatedObject).toEqual(expectedObject); 228 | }); 229 | // https://docs.mongodb.com/manual/reference/operator/update/push/#use-push-operator-with-multiple-modifiers 230 | // Looks like this is not fully supported in minimongo yet! 231 | // I get MinimongoError: $slice in $push must be zero or negative for field 'quizzes' 232 | //TODO When I change slice to negative, turns out I don't have minimongo sorter yet, will investigate this later 233 | it.skip("can update an array with multiple modifiers", () => { 234 | const myObject = { 235 | "_id" : 5, 236 | "quizzes" : [ 237 | { "wk": 1, "score" : 10 }, 238 | { "wk": 2, "score" : 8 }, 239 | { "wk": 3, "score" : 5 }, 240 | { "wk": 4, "score" : 6 } 241 | ] 242 | }; 243 | 244 | const updatedObject = modify(myObject, { 245 | $push: { 246 | quizzes: { 247 | $each: [ { wk: 5, score: 8 }, { wk: 6, score: 7 }, { wk: 7, score: 6 } ], 248 | $sort: { score: -1 }, 249 | $slice: 3 250 | } 251 | } 252 | }); 253 | 254 | const expectedObject = { 255 | "_id" : 5, 256 | "quizzes" : [ 257 | { "wk" : 1, "score" : 10 }, 258 | { "wk" : 2, "score" : 8 }, 259 | { "wk" : 5, "score" : 8 } 260 | ] 261 | }; 262 | 263 | expect(updatedObject).toEqual(expectedObject); 264 | }) 265 | }); 266 | // deprecated since mongo 2.4, based on the example of $push "appends multiple values to an array" above 267 | // 268 | describe("$pushAll", () => { 269 | it("appends multiple values to an array without $each", () => { 270 | const myObject = {name: "joe", scores: [50]}; 271 | // compare with - notice the $each 272 | // {$push: {scores: {$each: [90, 92, 85]}}}; 273 | 274 | const updatedObject = modify(myObject, {$pushAll: {scores: [90, 92, 85]}}); 275 | 276 | const expectedObject = update(myObject, { 277 | scores: {$push: [90, 92, 85]} 278 | }); 279 | 280 | expect(updatedObject).toEqual(expectedObject); 281 | }); 282 | }); 283 | // https://docs.mongodb.com/manual/reference/operator/update/addToSet/#behavior 284 | describe("$addToSet", () => { 285 | it("appends array with an array", () => { 286 | const myObject = { _id: 1, letters: ["a", "b"] }; 287 | 288 | const updatedObject = modify(myObject, {$addToSet: {letters: ["c", "d"]}}); 289 | 290 | const expectedObject = {_id: 1, letters: ["a", "b", ["c", "d"]]}; 291 | 292 | expect(updatedObject).toEqual(expectedObject); 293 | }); 294 | // https://docs.mongodb.com/manual/reference/operator/update/addToSet/#examples 295 | it("adds element to an array if element doesn't already exist", () => { 296 | const myObject = { _id: 1, item: "polarizing_filter", tags: [ "electronics", "camera" ] }; 297 | 298 | const updatedObject = modify(myObject, {$addToSet: {tags: "accessories"}}); 299 | 300 | const expectedObject = update(myObject, {tags: {$push: ["accessories"]}}); 301 | 302 | expect(updatedObject).toEqual(expectedObject); 303 | }); 304 | it("doesn't add an element to the array if it does already exists", () => { 305 | const myObject = { _id: 1, item: "polarizing_filter", tags: [ "electronics", "camera" ] }; 306 | 307 | const updatedObject = modify(myObject, {$addToSet: {tags: "camera"}}); 308 | 309 | const expectedObject = {...myObject}; 310 | 311 | expect(updatedObject).toEqual(expectedObject); 312 | }); 313 | // https://docs.mongodb.com/manual/reference/operator/update/addToSet/#each-modifier 314 | it("adds multiple values to the array field with $each modifier, omitting existing ones", () => { 315 | const myObject = { _id: 2, item: "cable", tags: [ "electronics", "supplies" ] }; 316 | 317 | const updatedObject = modify(myObject, {$addToSet: { tags: { $each: [ "camera", "electronics", "accessories" ] } }}); 318 | 319 | const expectedObject = update(myObject, {tags: {$push: ["camera", "accessories"]}}); 320 | 321 | expect(updatedObject).toEqual(expectedObject); 322 | }); 323 | }); 324 | describe("$pop", () => { 325 | // https://docs.mongodb.com/manual/reference/operator/update/pop/#remove-the-first-item-of-an-array 326 | it("removes the first element from an array", () => { 327 | const myObject = { _id: 1, scores: [ 8, 9, 10 ] }; 328 | 329 | const updatedObject = modify(myObject, {$pop: {scores: -1}}); 330 | 331 | const expectedObject = {_id: 1, scores: [9, 10]}; 332 | expect(updatedObject).toEqual(expectedObject); 333 | }); 334 | // https://docs.mongodb.com/manual/reference/operator/update/pop/#remove-the-last-item-of-an-array 335 | it("removes the last item of an array", () => { 336 | const myObject = {_id: 1, scores: [ 9, 10 ]}; 337 | 338 | const updatedObject = modify(myObject, { $pop: { scores: 1 } }); 339 | 340 | const expectedObject = {_id: 1, scores: [ 9 ]}; 341 | expect(updatedObject).toEqual(expectedObject); 342 | }) 343 | }); 344 | describe("$pull", () => { 345 | // https://docs.mongodb.com/manual/reference/operator/update/pull/#remove-all-items-that-equals-a-specified-value 346 | it.skip("removes all items that equals a specified value", () => { 347 | const myObject = { 348 | _id: 1, 349 | fruits: [ "apples", "pears", "oranges", "grapes", "bananas" ], 350 | vegetables: [ "carrots", "celery", "squash", "carrots" ] 351 | }; 352 | 353 | const updatedObject = modify(myObject, { $pull: { fruits: { $in: [ "apples", "oranges" ] }, vegetables: "carrots" } }); 354 | 355 | const expectedObject = { 356 | "_id" : 1, 357 | "fruits" : [ "pears", "grapes", "bananas" ], 358 | "vegetables" : [ "celery", "squash" ] 359 | }; 360 | // notice two carrots missing from the vegetables array 361 | expect(updatedObject).toEqual(expectedObject); 362 | }); 363 | // https://docs.mongodb.com/manual/reference/operator/update/pull/#remove-all-items-that-match-a-specified-pull-condition 364 | it.skip("Remove All Items That Match a Specified $pull Condition", () => { 365 | const myObject = { _id: 1, votes: [ 3, 5, 6, 7, 7, 8 ] }; 366 | 367 | const updatedObject = modify(myObject, { $pull: { votes: { $gte: 6 } } }); 368 | 369 | const expectedObject = { _id: 1, votes: [ 3, 5 ] }; 370 | 371 | expect(updatedObject).toEqual(expectedObject); 372 | }) 373 | }); 374 | describe("$pullAll", () => { 375 | // https://docs.mongodb.com/manual/reference/operator/update/pullAll/#up._S_pullAll 376 | it("removes all instances of the specified values from an existing array", () => { 377 | const myObject = { _id: 1, scores: [ 0, 2, 5, 5, 1, 0 ] }; 378 | 379 | const updatedObject = modify(myObject, {$pullAll: {scores: [0, 5]}}); 380 | 381 | const expectedObject = {_id: 1, scores: [2, 1]}; 382 | 383 | expect(updatedObject).toEqual(expectedObject); 384 | }); 385 | }); 386 | // https://docs.mongodb.com/manual/reference/operator/update/rename/ 387 | describe("$rename", () => { 388 | it("updates the name of a field", () => { 389 | const myObject = { 390 | "_id": 1, 391 | "alias": [ "The American Cincinnatus", "The American Fabius" ], 392 | "mobile": "555-555-5555", 393 | "nmae": { "first" : "george", "last" : "washington" } 394 | }; 395 | 396 | const updatedObject = modify(myObject, {$rename: {"nmae": "name"}}); 397 | 398 | const expectedObject = {...myObject}; 399 | delete expectedObject.nmae; 400 | expectedObject.name = {...myObject.nmae}; 401 | 402 | expect(updatedObject).toEqual(expectedObject); 403 | }) 404 | }); 405 | 406 | 407 | it("throws an error when the operand path contains an empty field name", () => { 408 | expect(() => { modify({}, {$set: {"test.abc.": "name"}}) }).toThrow(/empty field name/); 409 | }) 410 | 411 | }); --------------------------------------------------------------------------------