├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .jscsrc ├── .npmignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── package-lock.json ├── package.json └── src ├── index.js └── test └── testOperations.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-transform-runtime", 7 | "@babel/plugin-syntax-dynamic-import", 8 | "@babel/plugin-syntax-import-meta", 9 | "@babel/plugin-proposal-class-properties", 10 | "@babel/plugin-proposal-json-strings", 11 | [ 12 | "@babel/plugin-proposal-decorators", 13 | { 14 | "legacy": true 15 | } 16 | ], 17 | "@babel/plugin-proposal-function-sent", 18 | "@babel/plugin-proposal-export-namespace-from", 19 | "@babel/plugin-proposal-numeric-separator", 20 | "@babel/plugin-proposal-throw-expressions" 21 | ], 22 | "env": { 23 | "es": { 24 | "presets": [ 25 | [ 26 | "@babel/preset-env", 27 | { 28 | "modules": false 29 | } 30 | ] 31 | ] 32 | }, 33 | "cjs": { 34 | "presets": [ 35 | "@babel/preset-env" 36 | ] 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | */node_modules/* -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "env": { 4 | "mocha": true 5 | }, 6 | "rules": { 7 | "indent": [2, 4], 8 | "no-unused-vars": 1, 9 | "id-length": 0, 10 | "no-unused-expressions": 0, 11 | "prefer-rest-params": 0, 12 | "no-underscore-dangle": ["warn", { "allowAfterThis": true }], 13 | "no-param-reassign": ["error", { "props": false }], 14 | "max-len": 1, 15 | "prefer-template": 1, 16 | "consistent-return": 1, 17 | "no-prototype-builtins": 0, 18 | "no-restricted-syntax": [ 19 | "error", 20 | "ForOfStatement", 21 | "LabeledStatement", 22 | "WithStatement" 23 | ], 24 | "no-plusplus": 0, 25 | "arrow-parens": 0, 26 | "import/no-extraneous-dependencies": 0, 27 | "comma-dangle": ["error", { 28 | "arrays": "always-multiline", 29 | "objects": "always-multiline", 30 | "imports": "always-multiline", 31 | "exports": "always-multiline", 32 | "functions": "ignore", 33 | }] 34 | }, 35 | "parserOptions": { 36 | "ecmaVersion": 6 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.sublime-project 3 | *.sublime-workspace 4 | .DS_Store 5 | lib 6 | es 7 | .publish 8 | dist 9 | min 10 | *.log 11 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "airbnb", 3 | "validateIndentation": 4 4 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "10" 5 | - "8" 6 | 7 | script: 8 | - npm run lint 9 | - npm run build 10 | - npm run test 11 | 12 | cache: 13 | directories: 14 | - "$HOME/.npm" 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Tommi Kaikkonen 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=node_modules/.bin 2 | 3 | MOCHA_ARGS= --require @babel/register 4 | MOCHA_TARGET=src/**/test*.js 5 | 6 | clean: 7 | rm -rf lib es 8 | 9 | build: clean 10 | BABEL_ENV=cjs $(BIN)/babel src --out-dir lib 11 | BABEL_ENV=es $(BIN)/babel src --out-dir es 12 | 13 | test: lint 14 | NODE_ENV=test $(BIN)/mocha $(MOCHA_ARGS) $(MOCHA_TARGET) 15 | 16 | test-watch: lint 17 | NODE_ENV=test $(BIN)/mocha $(MOCHA_ARGS) -w $(MOCHA_TARGET) 18 | 19 | lint: 20 | $(BIN)/eslint src 21 | 22 | PHONY: build clean test test-watch lint 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | immutable-ops 2 | =============== 3 | 4 | [![NPM package](https://img.shields.io/npm/v/immutable-ops.svg?style=flat-square)](https://www.npmjs.com/package/immutable-ops) 5 | ![GitHub Release Date](https://img.shields.io/github/release-date/tommikaikkonen/immutable-ops.svg?style=flat-square) 6 | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/immutable-ops.svg?style=flat-square) 7 | [![NPM downloads](https://img.shields.io/npm/dm/immutable-ops.svg?style=flat-square)](https://www.npmjs.com/package/immutable-ops) 8 | ![NPM license](https://img.shields.io/npm/l/immutable-ops.svg?style=flat-square) 9 | 10 | A collection of functions to perform immutable operations on plain JavaScript objects and arrays. 11 | 12 | Like [updeep](https://github.com/substantial/updeep) but with batched mutations and no freezing. 13 | 14 | Like [icepick](https://github.com/aearly/icepick), but with batched mutations and a curried API that puts the target object as the last argument. No freezing. 15 | 16 | ## Features 17 | 18 | - Small. It's just 10 functions. 19 | - Functional API with curried functions 20 | - JavaScript in, JavaScript out 21 | - Batched mutations 22 | 23 | ## Installation 24 | 25 | ```bash 26 | npm install immutable-ops --save 27 | ``` 28 | 29 | ## Example Usage 30 | 31 | ```javascript 32 | import compose from 'ramda/src/compose'; 33 | import ops from 'immutable-ops'; 34 | 35 | // These are all the available functions. 36 | const { 37 | // Functions operating on objects. 38 | merge, 39 | mergeDeep, 40 | omit, 41 | setIn, 42 | 43 | // Functions operating on arrays. 44 | insert, 45 | splice, 46 | push, 47 | filter, 48 | 49 | // Functions operating on both 50 | set, 51 | 52 | // Placeholder for currying. 53 | __, 54 | } = ops; 55 | 56 | const arr = [1, 2, 3]; 57 | 58 | const pushFour = ops.push(4); 59 | const pushFive = ops.push(5); 60 | 61 | // All functions are curried. These functions 62 | // still need the final argument, the array to 63 | // operate on. 64 | expect(pushFive).to.be.a('function'); 65 | 66 | const pushFourAndFive = compose(pushFive, pushFour); 67 | 68 | const result = pushFourAndFive(arr); 69 | // Two new arrays were created during `pushFourAndFive` execution. 70 | expect(result).to.deep.equal([1, 2, 3, 4, 5]); 71 | 72 | 73 | 74 | // Only one new array is created. 75 | const sameResult = ops.batched(batchedOps => { 76 | // batchedOps is able to keep track of mutated 77 | // objects. 78 | return compose( 79 | batchedOps.push(5), 80 | batchedOps.push(4) 81 | )(arr); 82 | }); 83 | 84 | expect(sameResult).to.deep.equal([1, 2, 3, 4, 5]); 85 | ``` 86 | 87 | ## Batched Mutations 88 | 89 | A batch token is supplied by the user at the start of a batch, or created by `immutable-ops`. Each newly created object within a batch is tagged with that token. If a batch using token `X` operates on an object that is tagged with token `X`, it is free to mutate it. You can think of it as an ownership; the batch owns the newly created object and therefore is free to mutate it. New batches use a token `Y` that will never be equal to the previous token. 90 | 91 | Tags are not removed; They are assigned to a non-enumerable property `@@_______immutableOpsOwnerID` which should avoid any collisions. 92 | 93 | This token strategy is similar to what ImmutableJS uses to track batches. 94 | 95 | **Manually using batch tokens** 96 | 97 | `ops.batch` gives you access to all the `immutable-ops` functions that take a token as their additional first argument. Otherwise they are identical to the functions found in `ops` directly. 98 | 99 | ```javascript 100 | import ops from 'immutable-ops'; 101 | const token = ops.getBatchToken(); 102 | 103 | // This object has no batch token, since it was not created by immutable-ops. 104 | const obj = {a: 1, b: 2}; 105 | 106 | // obj2 is a newly created object tagged with the token. 107 | const obj2 = ops.batch.set(token, 'a', 10, obj); 108 | expect(obj).to.not.equal(obj2) 109 | 110 | // Because we operate on obj2 that has the same token as 111 | // we passed to the function, obj2 is mutated. 112 | const obj3 = ops.batch.set(token, 'b', 20, obj2); 113 | expect(obj2).to.equal(obj3); 114 | ``` 115 | 116 | 117 | **Handling batch tokens implicitly** 118 | 119 | ```javascript 120 | import ops from 'immutable-ops'; 121 | 122 | const obj = {a: 1, b: 2}; 123 | 124 | const obj3 = ops.batched(batchedOps => { 125 | // batchedOps has functions that are bound to a new batch token. 126 | const obj2 = batchedOps.set('a', 10, obj); 127 | return batchedOps.set('b', 20, obj2); 128 | }); 129 | ``` 130 | 131 | ## Currying 132 | 133 | All operations are curried by default. Functions are curried with `ramda.curry`. In addition to normal currying behaviour, you can use the `ramda` placeholder variable available in `ops.__` to specify parameters you want to pass arguments for later. Example: 134 | 135 | ```javascript 136 | const removeNFromHead = ops.splice(/* startIndex */ 0, /* deleteCount */ops.__, /* valsToAdd */[]); 137 | const removeTwoFromHead = removeNFromHead(2); 138 | const arr = [1, 2, 3]; 139 | 140 | console.log(removeTwoFromHead(arr)); 141 | // [3]; 142 | ``` 143 | 144 | ## Object API 145 | 146 | ### merge(mergeObj, targetObj) 147 | 148 | Performs a shallow merge on `targetObj`. `mergeObj` can be a single object to merge, or a list of objects. If a list is passed as `mergeObj`, objects to the right in the list will have priority when determining final attributes. 149 | 150 | Returns the merged object, which will be a different object if an actual change was detected during the merge. 151 | 152 | ```javascript 153 | const result = ops.merge( 154 | // mergeObj 155 | { 156 | a: 'theA', 157 | b: { 158 | c: 'nestedC', 159 | }, 160 | }, 161 | // targetObj 162 | { 163 | a: 'theA2', 164 | b: { 165 | d: 'nestedD', 166 | }, 167 | c: 'theC', 168 | } 169 | ); 170 | 171 | console.log(result); 172 | // { 173 | // { 174 | // a: 'theA', 175 | // b: { 176 | // c: 'nestedC' 177 | // }, 178 | // c: 'theC', 179 | // }, 180 | // } 181 | ``` 182 | 183 | ### deepMerge(mergeObj, targetObj) 184 | 185 | Same as `merge`, but performs `merge` recursively on attributes that are objects (not arrays). 186 | 187 | ```javascript 188 | const result = ops.deepMerge( 189 | // mergeObj 190 | { 191 | a: 'theA', 192 | b: { 193 | c: 'nestedC', 194 | }, 195 | }, 196 | // targetObj 197 | { 198 | a: 'theA2', 199 | b: { 200 | d: 'nestedD', 201 | }, 202 | c: 'theC', 203 | } 204 | ); 205 | 206 | console.log(result); 207 | // { 208 | // { 209 | // a: 'theA', 210 | // b: { 211 | // c: 'nestedC', 212 | // d: 'nestedD', 213 | // }, 214 | // c: 'theC', 215 | // }, 216 | // } 217 | ``` 218 | 219 | ### setIn(path, value, targetObj) 220 | 221 | Returns an object, with the value at `path` set to `value`. `path` can be a dot-separated list of attribute values or an array of attribute names to traverse. 222 | 223 | ```javascript 224 | 225 | const obj = { 226 | location: { 227 | city: 'San Francisco', 228 | }, 229 | }; 230 | 231 | const newObj = ops.setIn(['location', 'city'], 'Helsinki', obj); 232 | console.log(newObj); 233 | // { 234 | // location: { 235 | // city: 'Helsinki', 236 | // }, 237 | // }; 238 | ``` 239 | 240 | ### omit(keysToOmit, targetObj) 241 | 242 | Returns a shallow copy of `targetObj` without the keys specified in `keysToOmit`. `keysToOmit` can be a single key name or an array of key names. 243 | 244 | ```javascript 245 | const obj = { 246 | a: true, 247 | b: true, 248 | }; 249 | 250 | const result = ops.omit('a', obj); 251 | 252 | console.log(result); 253 | // { 254 | // b: true, 255 | // } 256 | ``` 257 | 258 | ## Array API 259 | 260 | ### insert(startIndex, values, targetArray) 261 | 262 | Returns a new array with `values` inserted at starting at index `startIndex` to `targetArray`. 263 | 264 | ```javascript 265 | const arr = [1, 2, 4]; 266 | const result = ops.insert(2, [3], arr); 267 | console.log(result); 268 | // [1, 2, 3, 4] 269 | ``` 270 | 271 | ### push(value, targetArray) 272 | 273 | Returns a shallow copy of `targetArray` with `value` added to the end. `value` can be a single value or an array of values to push. 274 | 275 | ```javascript 276 | const arr = [1, 2, 3]; 277 | const result = ops.push(4, arr); 278 | console.log(result); 279 | // [1, 2, 3, 4] 280 | ``` 281 | 282 | ### filter(func, targetArray) 283 | 284 | Returns a shallow copy of `targetArray` with items that `func` returns `true` for, when calling it with the item. 285 | 286 | ```javascript 287 | const arr = [1, 2, 3, 4]; 288 | const result = ops.filter(item => item % 2 === 0, arr); 289 | console.log(result); 290 | // [2, 4] 291 | ``` 292 | 293 | ### splice(startIndex, deleteCount, values, targetArray) 294 | 295 | Like `Array.prototype.splice`, but operates on a shallow copy of `targetArray` and returns the shallow copy. 296 | 297 | ```javascript 298 | const arr = [1, 2, 3, 3, 3, 4]; 299 | const result = ops.splice(2, 2, [], arr); 300 | console.log(result); 301 | // [1, 2, 3, 4] 302 | ``` 303 | 304 | ## API for both Object and Array 305 | 306 | ### set(key, value, target) 307 | 308 | Returns a shallow copy of `target` with its value at index or key `key` set to `value`. 309 | 310 | ```javascript 311 | const arr = [1, 2, 5]; 312 | const result = ops.set(2, 3, arr); 313 | console.log(result); 314 | // [1, 2, 3] 315 | 316 | const obj = { 317 | a: 'X', 318 | b: 'theB', 319 | }; 320 | const resultObj = ops.set('a', 'theA', obj); 321 | console.log(resultObj); 322 | // { 323 | // a: 'theA', 324 | // b: 'theB', 325 | // } 326 | ``` 327 | 328 | ## Changelog 329 | 330 | ## 0.5.0: Major Changes 331 | 332 | - **BREAKING**: No `getImmutableOps` function, which was the main export, is exported anymore because options were removed. Now the object containing the operation functions is exported directly. 333 | - **BREAKING**: removed option to choose whether operations are curried. Functions are now always curried. 334 | - **BREAKING**: former batched mutations API totally replaced. 335 | - **BREAKING**: batched mutations implementation changed. 336 | 337 | Previously newly created objects were tagged with a "can mutate" tag, and references to those objects were kept in a list. After the batch was finished, the list was processed by removing the tags from each object in the list. 338 | 339 | Now a batch token is created at the start of a batch (or supplied by the user). Each newly created object is tagged with that token. If a batch using token `X` operates on an object that is tagged with token `X`, it is free to mutate it. New batches use a token `Y` that will never be equal to the previous token. 340 | 341 | Tags are not removed anymore; They are assigned to a non-enumerable property `@@_______immutableOpsOwnerID` which should avoid any collisions. 342 | 343 | This token strategy is similar to what ImmutableJS uses to track batches. 344 | 345 | ## License 346 | 347 | MIT. See [`LICENSE`](https://github.com/tommikaikkonen/immutable-ops/blob/master/LICENSE). 348 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immutable-ops", 3 | "version": "0.7.0", 4 | "description": "A collection of functions to perform immutable operations on plain JavaScript objects", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "scripts": { 8 | "test": "make test", 9 | "prepare": "make build", 10 | "build": "make build", 11 | "lint": "make lint" 12 | }, 13 | "keywords": [], 14 | "author": "Tommi Kaikkonen ", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/tommikaikkonen/immutable-ops.git" 18 | }, 19 | "license": "MIT", 20 | "devDependencies": { 21 | "@babel/cli": "^7.4.3", 22 | "@babel/core": "^7.4.3", 23 | "@babel/plugin-proposal-class-properties": "^7.4.0", 24 | "@babel/plugin-proposal-decorators": "^7.4.0", 25 | "@babel/plugin-proposal-export-namespace-from": "^7.2.0", 26 | "@babel/plugin-proposal-function-sent": "^7.2.0", 27 | "@babel/plugin-proposal-json-strings": "^7.2.0", 28 | "@babel/plugin-proposal-numeric-separator": "^7.2.0", 29 | "@babel/plugin-proposal-throw-expressions": "^7.2.0", 30 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 31 | "@babel/plugin-syntax-import-meta": "^7.2.0", 32 | "@babel/plugin-transform-runtime": "^7.4.3", 33 | "@babel/preset-env": "^7.4.3", 34 | "@babel/register": "^7.4.0", 35 | "babel-eslint": "^10.0.1", 36 | "chai": "^4.2.0", 37 | "deep-freeze": "0.0.1", 38 | "eslint": "^5.16.0", 39 | "eslint-config-airbnb-base": "^13.1.0", 40 | "eslint-plugin-import": "^2.17.2", 41 | "mocha": "^6.1.3", 42 | "sinon": "^7.3.2", 43 | "sinon-chai": "^3.3.0" 44 | }, 45 | "dependencies": { 46 | "@babel/runtime": "^7.0.0", 47 | "ramda": "^0.26.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { curry, __ as placeholder } from 'ramda'; 2 | 3 | function forOwn(obj, fn) { 4 | for (const key in obj) { 5 | if (obj.hasOwnProperty(key)) { 6 | fn(obj[key], key); 7 | } 8 | } 9 | } 10 | 11 | function isArrayLike(value) { 12 | return value 13 | && typeof value === 'object' 14 | && typeof value.length === 'number' 15 | && value.length >= 0 16 | && value.length % 1 === 0; 17 | } 18 | 19 | const OWNER_ID_TAG = '@@_______immutableOpsOwnerID'; 20 | 21 | function fastArrayCopy(arr) { 22 | const copied = new Array(arr.length); 23 | for (let i = 0; i < arr.length; i++) { 24 | copied[i] = arr[i]; 25 | } 26 | return copied; 27 | } 28 | 29 | export function canMutate(obj, ownerID) { 30 | if (!ownerID) return false; 31 | return obj[OWNER_ID_TAG] === ownerID; 32 | } 33 | 34 | const newOwnerID = typeof Symbol === 'function' 35 | ? () => Symbol('ownerID') 36 | : () => ({}); 37 | 38 | export const getBatchToken = newOwnerID; 39 | 40 | function addOwnerID(obj, ownerID) { 41 | Object.defineProperty(obj, OWNER_ID_TAG, { 42 | value: ownerID, 43 | configurable: true, 44 | enumerable: false, 45 | }); 46 | 47 | return obj; 48 | } 49 | 50 | function prepareNewObject(instance, ownerID) { 51 | if (ownerID) { 52 | addOwnerID(instance, ownerID); 53 | } 54 | return instance; 55 | } 56 | 57 | function forceArray(arg) { 58 | if (!(arg instanceof Array)) { 59 | return [arg]; 60 | } 61 | return arg; 62 | } 63 | 64 | const PATH_SEPARATOR = '.'; 65 | 66 | function normalizePath(pathArg) { 67 | if (typeof pathArg === 'string') { 68 | if (pathArg.indexOf(PATH_SEPARATOR) === -1) { 69 | return [pathArg]; 70 | } 71 | return pathArg.split(PATH_SEPARATOR); 72 | } 73 | 74 | return pathArg; 75 | } 76 | 77 | function mutableSet(key, value, obj) { 78 | obj[key] = value; 79 | return obj; 80 | } 81 | 82 | function mutableSetIn(_pathArg, value, obj) { 83 | const originalPathArg = normalizePath(_pathArg); 84 | 85 | const pathLen = originalPathArg.length; 86 | 87 | let done = false; 88 | let idx = 0; 89 | let acc = obj; 90 | let curr = originalPathArg[idx]; 91 | 92 | while (!done) { 93 | if (idx === pathLen - 1) { 94 | acc[curr] = value; 95 | done = true; 96 | } else { 97 | const currType = typeof acc[curr]; 98 | 99 | if (currType === 'undefined') { 100 | const newObj = {}; 101 | prepareNewObject(newObj, null); 102 | acc[curr] = newObj; 103 | } else if (currType !== 'object') { 104 | const pathRepr = `${originalPathArg[idx - 1]}.${curr}`; 105 | throw new Error( 106 | `A non-object value was encountered when traversing setIn path at ${pathRepr}.` 107 | ); 108 | } 109 | acc = acc[curr]; 110 | idx++; 111 | curr = originalPathArg[idx]; 112 | } 113 | } 114 | 115 | return obj; 116 | } 117 | 118 | function valueInPath(_pathArg, obj) { 119 | const pathArg = normalizePath(_pathArg); 120 | 121 | let acc = obj; 122 | for (let i = 0; i < pathArg.length; i++) { 123 | const curr = pathArg[i]; 124 | const currRef = acc[curr]; 125 | if (i === pathArg.length - 1) { 126 | return currRef; 127 | } 128 | 129 | if (typeof currRef === 'object') { 130 | acc = currRef; 131 | } else { 132 | return undefined; 133 | } 134 | } 135 | return undefined; 136 | } 137 | 138 | function immutableSetIn(ownerID, _pathArg, value, obj) { 139 | const pathArg = normalizePath(_pathArg); 140 | 141 | const currentValue = valueInPath(pathArg, obj); 142 | if (value === currentValue) return obj; 143 | 144 | const pathLen = pathArg.length; 145 | 146 | let acc; 147 | if (canMutate(obj, ownerID)) { 148 | acc = obj; 149 | } else { 150 | acc = Object.assign(prepareNewObject({}, ownerID), obj); 151 | } 152 | 153 | const rootObj = acc; 154 | 155 | pathArg.forEach((curr, idx) => { 156 | if (idx === pathLen - 1) { 157 | acc[curr] = value; 158 | return; 159 | } 160 | 161 | const currRef = acc[curr]; 162 | const currType = typeof currRef; 163 | 164 | if (currType === 'object') { 165 | if (canMutate(currRef, ownerID)) { 166 | acc = currRef; 167 | } else { 168 | const newObj = prepareNewObject({}, ownerID); 169 | acc[curr] = Object.assign(newObj, currRef); 170 | acc = newObj; 171 | } 172 | return; 173 | } 174 | 175 | if (currType === 'undefined') { 176 | const newObj = prepareNewObject({}, ownerID); 177 | acc[curr] = newObj; 178 | acc = newObj; 179 | return; 180 | } 181 | 182 | const pathRepr = `${pathArg[idx - 1]}.${curr}`; 183 | throw new Error(`A non-object value was encountered when traversing setIn path at ${pathRepr}.`); 184 | }); 185 | 186 | return rootObj; 187 | } 188 | 189 | function mutableMerge(isDeep, _mergeObjs, baseObj) { 190 | const mergeObjs = forceArray(_mergeObjs); 191 | 192 | if (isDeep) { 193 | mergeObjs.forEach(mergeObj => { 194 | forOwn(mergeObj, (value, key) => { 195 | if (isDeep && baseObj.hasOwnProperty(key)) { 196 | let assignValue; 197 | if (typeof value === 'object') { 198 | assignValue = mutableMerge(isDeep, [value], baseObj[key]); 199 | } else { 200 | assignValue = value; 201 | } 202 | 203 | baseObj[key] = assignValue; 204 | } else { 205 | baseObj[key] = value; 206 | } 207 | }); 208 | }); 209 | } else { 210 | Object.assign(baseObj, ...mergeObjs); 211 | } 212 | 213 | return baseObj; 214 | } 215 | 216 | const mutableShallowMerge = mutableMerge.bind(null, false); 217 | const mutableDeepMerge = mutableMerge.bind(null, true); 218 | 219 | function mutableOmit(_keys, obj) { 220 | const keys = forceArray(_keys); 221 | keys.forEach(key => { 222 | delete obj[key]; 223 | }); 224 | return obj; 225 | } 226 | 227 | function shouldMergeKey(obj, other, key) { 228 | return obj[key] !== other[key]; 229 | } 230 | 231 | function immutableMerge(isDeep, ownerID, _mergeObjs, obj) { 232 | if (canMutate(obj, ownerID)) return mutableMerge(isDeep, _mergeObjs, obj); 233 | const mergeObjs = forceArray(_mergeObjs); 234 | 235 | let hasChanges = false; 236 | let nextObject = obj; 237 | 238 | const willChange = () => { 239 | if (!hasChanges) { 240 | hasChanges = true; 241 | nextObject = Object.assign({}, obj); 242 | prepareNewObject(nextObject, ownerID); 243 | } 244 | }; 245 | 246 | mergeObjs.forEach(mergeObj => { 247 | forOwn(mergeObj, (mergeValue, key) => { 248 | if (isDeep && obj.hasOwnProperty(key)) { 249 | const currentValue = nextObject[key]; 250 | if (typeof mergeValue === 'object' && !(mergeValue instanceof Array)) { 251 | if (shouldMergeKey(nextObject, mergeObj, key)) { 252 | const recursiveMergeResult = immutableMerge( 253 | isDeep, ownerID, mergeValue, currentValue 254 | ); 255 | 256 | if (recursiveMergeResult !== currentValue) { 257 | willChange(); 258 | nextObject[key] = recursiveMergeResult; 259 | } 260 | } 261 | return true; // continue forOwn 262 | } 263 | } 264 | if (shouldMergeKey(nextObject, mergeObj, key)) { 265 | willChange(); 266 | nextObject[key] = mergeValue; 267 | } 268 | return undefined; 269 | }); 270 | }); 271 | 272 | return nextObject; 273 | } 274 | 275 | const immutableDeepMerge = immutableMerge.bind(null, true); 276 | const immutableShallowMerge = immutableMerge.bind(null, false); 277 | 278 | function immutableArrSet(ownerID, index, value, arr) { 279 | if (canMutate(arr, ownerID)) return mutableSet(index, value, arr); 280 | 281 | if (arr[index] === value) return arr; 282 | 283 | const newArr = fastArrayCopy(arr); 284 | newArr[index] = value; 285 | prepareNewObject(newArr, ownerID); 286 | 287 | return newArr; 288 | } 289 | 290 | function immutableSet(ownerID, key, value, obj) { 291 | if (isArrayLike(obj)) return immutableArrSet(ownerID, key, value, obj); 292 | if (canMutate(obj, ownerID)) return mutableSet(key, value, obj); 293 | 294 | if (obj[key] === value) return obj; 295 | 296 | const newObj = Object.assign({}, obj); 297 | prepareNewObject(newObj, ownerID); 298 | newObj[key] = value; 299 | return newObj; 300 | } 301 | 302 | function immutableOmit(ownerID, _keys, obj) { 303 | if (canMutate(obj, ownerID)) return mutableOmit(_keys, obj); 304 | 305 | const keys = forceArray(_keys); 306 | const keysInObj = keys.filter(key => obj.hasOwnProperty(key)); 307 | 308 | // None of the keys were in the object, so we can return `obj`. 309 | if (keysInObj.length === 0) return obj; 310 | 311 | const newObj = Object.assign({}, obj); 312 | keysInObj.forEach(key => { 313 | delete newObj[key]; 314 | }); 315 | prepareNewObject(newObj, ownerID); 316 | return newObj; 317 | } 318 | 319 | function mutableArrPush(_vals, arr) { 320 | const vals = forceArray(_vals); 321 | arr.push(...vals); 322 | return arr; 323 | } 324 | 325 | function mutableArrFilter(func, arr) { 326 | let currIndex = 0; 327 | let originalIndex = 0; 328 | while (currIndex < arr.length) { 329 | const item = arr[currIndex]; 330 | if (!func(item, originalIndex)) { 331 | arr.splice(currIndex, 1); 332 | } else { 333 | currIndex++; 334 | } 335 | originalIndex++; 336 | } 337 | 338 | return arr; 339 | } 340 | 341 | function mutableArrSplice(index, deleteCount, _vals, arr) { 342 | const vals = forceArray(_vals); 343 | arr.splice(index, deleteCount, ...vals); 344 | return arr; 345 | } 346 | 347 | function mutableArrInsert(index, _vals, arr) { 348 | return mutableArrSplice(index, 0, _vals, arr); 349 | } 350 | 351 | function immutableArrSplice(ownerID, index, deleteCount, _vals, arr) { 352 | if (canMutate(arr, ownerID)) return mutableArrSplice(index, deleteCount, _vals, arr); 353 | 354 | const vals = forceArray(_vals); 355 | const newArr = arr.slice(); 356 | prepareNewObject(newArr, ownerID); 357 | newArr.splice(index, deleteCount, ...vals); 358 | 359 | return newArr; 360 | } 361 | 362 | function immutableArrInsert(ownerID, index, _vals, arr) { 363 | if (canMutate(arr, ownerID)) return mutableArrInsert(index, _vals, arr); 364 | return immutableArrSplice(ownerID, index, 0, _vals, arr); 365 | } 366 | 367 | function immutableArrPush(ownerID, vals, arr) { 368 | return immutableArrInsert(ownerID, arr.length, vals, arr); 369 | } 370 | 371 | function immutableArrFilter(ownerID, func, arr) { 372 | if (canMutate(arr, ownerID)) return mutableArrFilter(func, arr); 373 | const newArr = arr.filter(func); 374 | 375 | if (newArr.length === arr.length) return arr; 376 | 377 | prepareNewObject(newArr, ownerID); 378 | return newArr; 379 | } 380 | 381 | const immutableOperations = { 382 | // object operations 383 | merge: immutableShallowMerge, 384 | deepMerge: immutableDeepMerge, 385 | omit: immutableOmit, 386 | setIn: immutableSetIn, 387 | 388 | // array operations 389 | insert: immutableArrInsert, 390 | push: immutableArrPush, 391 | filter: immutableArrFilter, 392 | splice: immutableArrSplice, 393 | 394 | // both 395 | set: immutableSet, 396 | }; 397 | 398 | const mutableOperations = { 399 | // object operations 400 | merge: mutableShallowMerge, 401 | deepMerge: mutableDeepMerge, 402 | omit: mutableOmit, 403 | setIn: mutableSetIn, 404 | 405 | // array operations 406 | insert: mutableArrInsert, 407 | push: mutableArrPush, 408 | filter: mutableArrFilter, 409 | splice: mutableArrSplice, 410 | 411 | // both 412 | set: mutableSet, 413 | }; 414 | 415 | 416 | export function getImmutableOps() { 417 | const immutableOps = Object.assign({}, immutableOperations); 418 | forOwn(immutableOps, (value, key) => { 419 | immutableOps[key] = curry(value.bind(null, null)); 420 | }); 421 | 422 | const mutableOps = Object.assign({}, mutableOperations); 423 | forOwn(mutableOps, (value, key) => { 424 | mutableOps[key] = curry(value); 425 | }); 426 | 427 | const batchOps = Object.assign({}, immutableOperations); 428 | forOwn(batchOps, (value, key) => { 429 | batchOps[key] = curry(value); 430 | }); 431 | 432 | function batched(_token, _fn) { 433 | let token; 434 | let fn; 435 | 436 | if (typeof _token === 'function') { 437 | fn = _token; 438 | token = getBatchToken(); 439 | } else { 440 | token = _token; 441 | fn = _fn; 442 | } 443 | 444 | const immutableOpsBoundToToken = Object.assign({}, immutableOperations); 445 | forOwn(immutableOpsBoundToToken, (value, key) => { 446 | immutableOpsBoundToToken[key] = curry(value.bind(null, token)); 447 | }); 448 | return fn(immutableOpsBoundToToken); 449 | } 450 | 451 | return Object.assign(immutableOps, { 452 | mutable: mutableOps, 453 | batch: batchOps, 454 | batched, 455 | __: placeholder, 456 | getBatchToken, 457 | }); 458 | } 459 | 460 | export const ops = getImmutableOps(); 461 | 462 | export default ops; 463 | -------------------------------------------------------------------------------- /src/test/testOperations.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import sinonChai from 'sinon-chai'; 3 | import freeze from 'deep-freeze'; 4 | 5 | import { ops, canMutate, getBatchToken } from '../index'; 6 | 7 | chai.use(sinonChai); 8 | const { expect } = chai; 9 | 10 | describe('batched', () => { 11 | it('works', () => { 12 | const res = ops.batched(batchOps => { 13 | const obj = {}; 14 | const result = batchOps.set('a', 1, obj); 15 | expect(result).to.deep.equal({ a: 1 }); 16 | expect(result).not.to.equal(obj); 17 | 18 | const result2 = batchOps.omit('a', result); 19 | expect(result2).to.equal(result); 20 | expect(result2).to.deep.equal({}); 21 | return result2; 22 | }); 23 | 24 | expect(res).to.deep.equal({}); 25 | }); 26 | }); 27 | 28 | describe('operations', () => { 29 | describe('object', () => { 30 | describe('batched mutations', () => { 31 | const token = getBatchToken(); 32 | it('deepMerges', () => { 33 | const baseObj = freeze({ 34 | change: 'Tommi', 35 | dontChange: 25, 36 | deeper: { 37 | dontChange: 'John', 38 | change: 30, 39 | }, 40 | }); 41 | const mergeObj = freeze({ 42 | change: 'None', 43 | add: 'US', 44 | deeper: { 45 | add: 'US', 46 | change: 35, 47 | }, 48 | }); 49 | const merger = ops.batch.deepMerge(token, mergeObj); 50 | const result = merger(baseObj); 51 | expect(canMutate(result, token)).to.be.true; 52 | expect(canMutate(result.deeper, token)).to.be.true; 53 | 54 | expect(canMutate(result, getBatchToken())).to.be.false; 55 | expect(canMutate(result.deeper, getBatchToken())).to.be.false; 56 | 57 | expect(result).to.not.equal(baseObj); 58 | 59 | expect(result).to.contain.all.keys(['change', 'dontChange', 'add', 'deeper']); 60 | expect(result.change).to.not.equal(baseObj.change); 61 | expect(result.dontChange).to.equal(baseObj.dontChange); 62 | 63 | expect(result.deeper).to.not.equal(baseObj.deeper); 64 | expect(result.deeper).to.contain.all.keys(['dontChange', 'change', 'add']); 65 | expect(result.deeper.dontChange).to.equal(baseObj.deeper.dontChange); 66 | expect(result.deeper.change).to.not.equal(baseObj.deeper.change); 67 | }); 68 | 69 | it('omits a single key', () => { 70 | const obj = freeze({ 71 | name: 'Tommi', 72 | age: 25, 73 | }); 74 | 75 | const omitter = ops.batch.omit(token, 'age'); 76 | 77 | const result = omitter(obj); 78 | expect(canMutate(result, token)).to.be.true; 79 | 80 | expect(canMutate(result, getBatchToken())).to.be.false; 81 | expect(result).to.not.contain.keys(['age']); 82 | 83 | // Further modification should mutate the existing object. 84 | expect(ops.batch.omit(token, 'name', result)).to.equal(result); 85 | }); 86 | 87 | it('omits an array of keys', () => { 88 | const obj = freeze({ 89 | name: 'Tommi', 90 | age: 25, 91 | }); 92 | 93 | const omitter = ops.batch.omit(token, ['age']); 94 | const result = omitter(obj); 95 | 96 | expect(canMutate(result, token)).to.be.true; 97 | 98 | expect(canMutate(result, getBatchToken())).to.be.false; 99 | expect(result).to.not.contain.keys(['age']); 100 | 101 | // Further modification should mutate the existing object. 102 | expect(ops.batch.omit(token, ['name'], result)).to.equal(result); 103 | }); 104 | 105 | it('sets a value', () => { 106 | const obj = freeze({ 107 | one: 1, 108 | two: 500, 109 | three: 3, 110 | }); 111 | 112 | const result = ops.batch.set(token, 'two', 5, obj); 113 | 114 | expect(canMutate(result, token)).to.be.true; 115 | const result2 = ops.batch.set(token, 'two', 2, result); 116 | expect(result2).to.deep.equal({ 117 | one: 1, 118 | two: 2, 119 | three: 3, 120 | }); 121 | 122 | expect(result).to.equal(result2); 123 | }); 124 | 125 | it('sets a value in path', () => { 126 | const obj = freeze({ 127 | first: { 128 | second: { 129 | value: 'value', 130 | maintain: true, 131 | }, 132 | maintain: true, 133 | }, 134 | maintain: true, 135 | }); 136 | 137 | const setter = ops.batch.setIn(token, 'first.second.value', 'anotherValue'); 138 | 139 | const result = setter(obj); 140 | expect(canMutate(result, token)).to.be.true; 141 | 142 | expect(canMutate(result, getBatchToken())).to.be.false; 143 | expect(result).not.to.equal(obj); 144 | expect(result.first.second.value).to.equal('anotherValue'); 145 | expect(result.maintain).to.be.true; 146 | expect(result.first.maintain).to.be.true; 147 | expect(result.first.second.maintain).to.be.true; 148 | 149 | const result2 = ops.batch.setIn(token, 'first.second.value', 'secondAnotherValue', result); 150 | expect(result).to.equal(result2); 151 | expect(result2.first.second.value).to.equal('secondAnotherValue'); 152 | }); 153 | }); 154 | 155 | describe('immutable ops', () => { 156 | it('deepMerges', () => { 157 | const baseObj = freeze({ 158 | change: 'Tommi', 159 | dontChange: 25, 160 | deeper: { 161 | dontChange: 'John', 162 | change: 30, 163 | }, 164 | }); 165 | const mergeObj = freeze({ 166 | change: 'None', 167 | add: 'US', 168 | deeper: { 169 | add: 'US', 170 | change: 35, 171 | }, 172 | }); 173 | 174 | const merger = ops.deepMerge(mergeObj); 175 | const result = merger(baseObj); 176 | 177 | expect(canMutate(result)).to.be.false; 178 | expect(canMutate(result.deeper)).to.be.false; 179 | 180 | expect(result).to.not.equal(baseObj); 181 | 182 | expect(result).to.contain.all.keys(['change', 'dontChange', 'add', 'deeper']); 183 | expect(result.change).to.not.equal(baseObj.change); 184 | expect(result.dontChange).to.equal(baseObj.dontChange); 185 | 186 | expect(result.deeper).to.not.equal(baseObj.deeper); 187 | expect(result.deeper).to.contain.all.keys(['dontChange', 'change', 'add']); 188 | expect(result.deeper.dontChange).to.equal(baseObj.deeper.dontChange); 189 | expect(result.deeper.change).to.not.equal(baseObj.deeper.change); 190 | }); 191 | 192 | it('deepMerges and returns initial object when no values changed', () => { 193 | const baseObj = freeze({ 194 | deep: { 195 | dontChange: 'John', 196 | }, 197 | }); 198 | const mergeObj = freeze({ 199 | deep: { 200 | dontChange: 'John', 201 | }, 202 | }); 203 | 204 | const result = ops.deepMerge(mergeObj, baseObj); 205 | expect(result).to.equal(baseObj); 206 | }); 207 | 208 | it('omits a single key', () => { 209 | const obj = freeze({ 210 | name: 'Tommi', 211 | age: 25, 212 | }); 213 | 214 | const omitter = ops.omit('age'); 215 | const result = omitter(obj); 216 | 217 | expect(canMutate(result)).to.be.false; 218 | expect(result).to.not.contain.keys(['age']); 219 | }); 220 | 221 | it('omits a single key, returns same object if no value changes', () => { 222 | const obj = freeze({ 223 | name: 'Tommi', 224 | age: 25, 225 | }); 226 | 227 | const result = ops.omit('location', obj); 228 | expect(result).to.equal(obj); 229 | }); 230 | 231 | it('omits an array of keys', () => { 232 | const obj = freeze({ 233 | name: 'Tommi', 234 | age: 25, 235 | }); 236 | 237 | const omitter = ops.omit(['age']); 238 | const result = omitter(obj); 239 | 240 | expect(canMutate(result)).to.be.false; 241 | expect(result).to.not.contain.keys(['age']); 242 | }); 243 | 244 | it('sets a value', () => { 245 | const obj = freeze({ 246 | name: 'Tommi', 247 | age: 25, 248 | }); 249 | 250 | const result = ops.set('age', 26, obj); 251 | expect(result).to.deep.equal({ 252 | name: 'Tommi', 253 | age: 26, 254 | }); 255 | }); 256 | 257 | it('sets a value and returns the initial value of no changes', () => { 258 | const obj = freeze({ 259 | name: 'Tommi', 260 | age: 25, 261 | }); 262 | 263 | const result = ops.set('age', 25, obj); 264 | expect(result).to.equal(obj); 265 | }); 266 | 267 | it('sets a value in path', () => { 268 | const obj = freeze({ 269 | first: { 270 | second: { 271 | value: 'value', 272 | maintain: true, 273 | }, 274 | maintain: true, 275 | }, 276 | maintain: true, 277 | }); 278 | 279 | const setter = ops.setIn('first.second.value', 'anotherValue'); 280 | 281 | const result = setter(obj); 282 | 283 | expect(canMutate(result)).to.be.false; 284 | expect(result).not.to.equal(obj); 285 | expect(result.first.second.value).to.equal('anotherValue'); 286 | expect(result.maintain).to.be.true; 287 | expect(result.first.maintain).to.be.true; 288 | expect(result.first.second.maintain).to.be.true; 289 | }); 290 | 291 | it('sets a value in path but returns same object if no value changes', () => { 292 | const obj = freeze({ 293 | first: { 294 | second: { 295 | value: 'value', 296 | maintain: true, 297 | }, 298 | maintain: true, 299 | }, 300 | maintain: true, 301 | }); 302 | 303 | const result = ops.setIn('first.second.value', 'value', obj); 304 | expect(result).to.equal(obj); 305 | }); 306 | }); 307 | }); 308 | 309 | describe('array', () => { 310 | describe('batched mutations', () => { 311 | const token = getBatchToken(); 312 | 313 | it('push', () => { 314 | const { push } = ops.batch; 315 | const arr = freeze([5, 4]); 316 | const pusher = push(token, freeze([1, 2, 3])); 317 | const result = pusher(arr); 318 | 319 | expect(result).to.not.equal(arr); 320 | 321 | expect(result).to.deep.equal([5, 4, 1, 2, 3]); 322 | 323 | const result2 = push(token, [4, 5], result); 324 | expect(result).to.equal(result2); 325 | expect(result2).to.deep.equal([5, 4, 1, 2, 3, 4, 5]); 326 | }); 327 | 328 | it('insert', () => { 329 | const { insert } = ops.batch; 330 | const arr = freeze([1, 2, 5]); 331 | const inserter = insert(token, 2, freeze([3, 4])); 332 | const result = inserter(arr); 333 | 334 | expect(result).to.deep.equal([1, 2, 3, 4, 5]); 335 | 336 | const result2 = insert(token, 2, [1000], result); 337 | expect(result).to.equal(result2); 338 | expect(result2).to.deep.equal([1, 2, 1000, 3, 4, 5]); 339 | }); 340 | 341 | it('filter', () => { 342 | const arr = freeze([0, 1, 2, 3]); 343 | const result = ops.batch.filter(token, item => item % 2 === 0, arr); 344 | expect(canMutate(result, token)).to.be.true; 345 | 346 | expect(result).to.deep.equal([0, 2]); 347 | expect(canMutate(result, getBatchToken())).to.be.false; 348 | 349 | const result2 = ops.batch.filter(token, item => item === 2, result); 350 | expect(result2).to.equal(result); 351 | expect(result2).to.deep.equal([2]); 352 | }); 353 | 354 | it('set', () => { 355 | const { set } = ops.batch; 356 | const arr = freeze([1, 2, 987, 4]); 357 | 358 | const setter = set(token, 2, 3); 359 | const result = setter(arr); 360 | expect(canMutate(result, token)).to.be.true; 361 | 362 | expect(canMutate(result, getBatchToken())).to.be.false; 363 | expect(result).to.deep.equal([1, 2, 3, 4]); 364 | 365 | const result2 = set(token, 2, 1000, result); 366 | expect(result).to.equal(result2); 367 | expect(result2).to.deep.equal([1, 2, 1000, 4]); 368 | }); 369 | 370 | it('splice with deletions', () => { 371 | const { splice } = ops.batch; 372 | const arr = freeze([1, 2, 3, 3, 3, 4]); 373 | const splicer = splice(token, 2, 2, []); 374 | 375 | const result = splicer(arr); 376 | 377 | expect(result).to.deep.equal([1, 2, 3, 4]); 378 | 379 | const result2 = splice(token, 2, 1, [], result); 380 | expect(result2).to.equal(result); 381 | expect(result2).to.deep.equal([1, 2, 4]); 382 | }); 383 | 384 | it('splice with additions', () => { 385 | const { splice } = ops.batch; 386 | const arr = freeze([1, 5]); 387 | const splicer = splice(token, 1, 0, [2, 3, 4]); 388 | 389 | const result = splicer(arr); 390 | 391 | expect(result).to.deep.equal([1, 2, 3, 4, 5]); 392 | 393 | const result2 = splice(token, 0, 1, [1000], result); 394 | expect(result).to.equal(result2); 395 | expect(result2).to.deep.equal([1000, 2, 3, 4, 5]); 396 | }); 397 | }); 398 | 399 | describe('immutable ops', () => { 400 | it('push', () => { 401 | const { push } = ops; 402 | const arr = freeze([5, 4]); 403 | const pusher = push(freeze([1, 2, 3])); 404 | const result = pusher(arr); 405 | 406 | expect(result).to.not.equal(arr); 407 | 408 | expect(result).to.deep.equal([5, 4, 1, 2, 3]); 409 | }); 410 | 411 | it('insert', () => { 412 | const { insert } = ops; 413 | const arr = freeze([1, 2, 5]); 414 | const inserter = insert(2, freeze([3, 4])); 415 | const result = inserter(arr); 416 | 417 | expect(result).to.deep.equal([1, 2, 3, 4, 5]); 418 | }); 419 | 420 | it('filter', () => { 421 | const arr = freeze([0, 1, 2, 3]); 422 | 423 | const result = ops.filter(item => item % 2 === 0, arr); 424 | 425 | expect(result).to.deep.equal([0, 2]); 426 | expect(canMutate(result)).to.be.false; 427 | }); 428 | 429 | it('filter with no effect should return initial array', () => { 430 | const arr = freeze([0, 1, 2, 3]); 431 | const result = ops.filter(item => item < 4, arr); 432 | expect(result).to.equal(arr); 433 | }); 434 | 435 | it('set', () => { 436 | const arr = freeze([1, 2, 987, 4]); 437 | 438 | const result = ops.set(2, 3, arr); 439 | 440 | expect(canMutate(result)).to.be.false; 441 | expect(result).to.deep.equal([1, 2, 3, 4]); 442 | }); 443 | 444 | it('set with no effect should return initial array', () => { 445 | const arr = freeze([1, 2, 3, 4]); 446 | 447 | const result = ops.set(2, 3, arr); 448 | expect(result).to.equal(arr); 449 | }); 450 | 451 | it('splice with deletions', () => { 452 | const { splice } = ops; 453 | const arr = freeze([1, 2, 3, 3, 3, 4]); 454 | const splicer = splice(2, 2, []); 455 | 456 | const result = splicer(arr); 457 | expect(result).to.deep.equal([1, 2, 3, 4]); 458 | }); 459 | 460 | it('splice with additions', () => { 461 | const { splice } = ops; 462 | const arr = freeze([1, 5]); 463 | const splicer = splice(1, 0, [2, 3, 4]); 464 | 465 | const result = splicer(arr); 466 | 467 | expect(result).to.deep.equal([1, 2, 3, 4, 5]); 468 | }); 469 | }); 470 | }); 471 | }); 472 | --------------------------------------------------------------------------------