From e79e9a759f15599448d9d1e4d026b075f61bc6eb Mon Sep 17 00:00:00 2001 From: Tommi Kaikkonen Date: Wed, 10 Feb 2016 01:12:09 -0800 Subject: [PATCH] Initial commit --- .eslintignore | 1 + .eslintrc | 16 ++ .gitignore | 9 + .jscsrc | 4 + .npmignore | 1 + LICENSE | 21 ++ Makefile | 21 ++ README.md | 319 ++++++++++++++++++++++++++ package.json | 33 +++ src/index.js | 448 +++++++++++++++++++++++++++++++++++++ src/test/testOperations.js | 194 ++++++++++++++++ 11 files changed, 1067 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .jscsrc create mode 100644 .npmignore create mode 100644 LICENSE create mode 100755 Makefile create mode 100755 README.md create mode 100644 package.json create mode 100755 src/index.js create mode 100644 src/test/testOperations.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..93fd621 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +*/node_modules/* \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..bb7b57c --- /dev/null +++ b/.eslintrc @@ -0,0 +1,16 @@ +{ + "extends": "airbnb/base", + "env": { + "mocha": true + }, + "rules": { + "indent": [2, 4], + "no-unused-vars": 1, + "id-length": 0, + "no-unused-expressions": 0, + }, + "ecmaFeatures": { + "restParams": true, + "experimentalObjectRestSpread": true + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1328e64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +*.sublime-project +*.sublime-workspace +.DS_Store +lib +.publish +dist +min +*.log \ No newline at end of file diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 0000000..7c7a6ed --- /dev/null +++ b/.jscsrc @@ -0,0 +1,4 @@ +{ + "preset": "airbnb", + "validateIndentation": 4 +} \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..89a5433 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Tommi Kaikkonen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..8237034 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +BIN=node_modules/.bin + +MOCHA_ARGS= --compilers js:babel/register +MOCHA_TARGET=src/**/test*.js + +clean: + rm -rf lib + +build: clean + $(BIN)/babel src --out-dir lib + +test: lint + NODE_ENV=test $(BIN)/mocha $(MOCHA_ARGS) $(MOCHA_TARGET) + +test-watch: lint + NODE_ENV=test $(BIN)/mocha $(MOCHA_ARGS) -w $(MOCHA_TARGET) + +lint: + $(BIN)/eslint src + +PHONY: build clean test test-watch lint \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..ada2966 --- /dev/null +++ b/README.md @@ -0,0 +1,319 @@ +immutable-ops +=============== + +A collection of functions to perform immutable operations on plain JavaScript objects and arrays. Aims to have the simplicity and small size of `seamless-immutable`, but with a functional, curried API, no special immutable object type, and batched mutations. + +## Features + +- Small. It's just 11 functions. +- Functional API with curried functions +- JavaScript in, JavaScript out +- Batched mutations + +## Installation + +```bash +npm install immutable-ops +``` + +## Usage + +```javascript +import compose from 'ramda/src/compose'; +import getImmutableFns from 'immutable-functions'; + +// These are all the available functions. +const { + // Functions operating on objects. + merge, + mergeDeep, + omit, + setIn, + + // Functions operating on arrays. + insert, + splice, + push, + filter, + + // Functions operating on both + set, + + // Batch mutations + batched, + + // Placeholder for currying. + __, +} = getImmutableOps({ + // These are the default options. + curried: true +}); + +const person = { + name: 'Tommi', + age: 25, + location: { + city: 'New York', + country: 'US', + }, +}; + +// All functions are curried. This returns a function that should be +// called with the last argument, the object to be operated on. +const moveToNY = mergeDeep({ location: { city: 'New York' }}); +const moveToSF = mergeDeep({ location: { city: 'San Francisco' }}); + +const updatedPerson = moveToNY(person); + +// If any changes are not made, the same object is returned. +updatedPerson === person +// true + +const becomeABanker = setIn('occupation.title', 'Investment Banker'); +const advanceCareer = compose(becomeABanker, moveToNY) + +const personWithJob = advanceCareer(person); +console.log(personWithJob === person); +// false + +console.log(personWithJob); +// { +// name: 'Tommi', +// age: 25, +// location: { +// city: 'New York', +// country: 'US', +// }, +// occupation: { +// title: 'Investment Banker', +// } +// } + +const runOperations = compose(advanceCareer, moveToSf); +const personWithJobTwo = ops.batched(() => runOperations(person)); +console.log(person === personWithJobTwo) +// false + +// All data is still immutable. `ops.batched(() => runOperations(person))` returns a deeply equal result to the just running `runOperations(person)`. The difference is in the amount of objects created during `runOperations`. When `moveToSF` is first called, it creates a new object for the `location` key with the updated `city`. When `advanceCareer` calls `moveToNY`, that `location` object is mutated instead of a new one being created. +console.log(personWithJobTwo); +// { +// name: 'Tommi', +// age: 25, +// location: { +// city: 'New York', +// country: 'US', +// }, +// occupation: { +// title: 'Investment Banker', +// } +// } +``` + +## Batched Mutations + +You can batch operations by calling `ops.batched(func)` with a function. + +When `immutable-ops` creates a new object or array during batched mutations to preserve immutability, it tags it as a mutable object (by adding an unenumerable `@@_____canMutate` property) and pushes its reference to an array of `mutatedObjects`. All consecutive functions applied will execute a mutating operations for objects that have the tag. This applies for tagged objects found in nested structures too. + +When the function finishes executing, `immutable-ops` loops through the `mutatedObjects` array, removing the tag properties from each object, and clearing the `mutatedObjects` array. + +## Currying + +All operations are curried by default. If you don't want them to be curried, pass `{ curried: false }` to `getOps()`. 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: + +```javascript +const removeNFromHead = ops.splice(/* startIndex */ 0, /* deleteCount */ops.__, /* valsToAdd */[]); +const removeTwoFromHead = removeNFromHead(2); +const arr = [1, 2, 3]; + +console.log(removeTwoFromHead(arr)); +// [3]; +``` + +## Object API + +### merge(mergeObj, targetObj) + +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. + +Returns the merged object, which will be a different object if an actual change was detected during the merge. + +```javascript +const result = ops.merge( + // mergeObj + { + a: 'theA', + b: { + c: 'nestedC', + }, + }, + // targetObj + { + a: 'theA2', + b: { + d: 'nestedD', + }, + c: 'theC', + } +); + +console.log(result); +// { +// { +// a: 'theA', +// b: { +// c: 'nestedC' +// }, +// c: 'theC', +// }, +// } +``` + +### deepMerge(mergeObj, targetObj) + +Same as `merge`, but performs `merge` recursively on attributes that are objects (not arrays). + +```javascript +const result = ops.deepMerge( + // mergeObj + { + a: 'theA', + b: { + c: 'nestedC', + }, + }, + // targetObj + { + a: 'theA2', + b: { + d: 'nestedD', + }, + c: 'theC', + } +); + +console.log(result); +// { +// { +// a: 'theA', +// b: { +// c: 'nestedC', +// d: 'nestedD', +// }, +// c: 'theC', +// }, +// } +``` + +### setIn(path, value, targetObj) + +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. + +```javascript + +const obj = { + location: { + city: 'San Francisco', + }, +}; + +const newObj = ops.setIn(['location', 'city'], 'Helsinki', obj); +console.log(newObj); +// { +// location: { +// city: 'Helsinki', +// }, +// }; +``` + +### omit(keysToOmit, targetObj) + +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. + +```javascript +const obj = { + a: true, + b: true, +}; + +const result = ops.omit('a', obj); + +console.log(result); +// { +// b: true, +// } +``` + +## Array API + +### insert(startIndex, values, targetArray) + +Returns a new array with `values` inserted at starting at index `startIndex` to `targetArray`. + +```javascript +const arr = [1, 2, 4]; +const result = ops.insert(2, [3], arr); +console.log(result); +// [1, 2, 3, 4] +``` + +### push(value, targetArray) + +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. + +```javascript +const arr = [1, 2, 3]; +const result = ops.push(4, arr); +console.log(result); +// [1, 2, 3, 4] +``` + +### filter(func, targetArray) + +Returns a shallow copy of `targetArray` with items that `func` returns `true` for, when calling it with the item. + +```javascript +const arr = [1, 2, 3, 4]; +const result = ops.filter(item => item % 2 === 0, arr); +console.log(result); +// [2, 4] +``` + +### splice(startIndex, deleteCount, values, targetArray) + +Like `Array.prototype.splice`, but operates on a shallow copy of `targetArray` and returns the shallow copy. + +```javascript +const arr = [1, 2, 3, 3, 3, 4]; +const result = ops.splice(2, 2, [], arr); +console.log(result); +// [1, 2, 3, 4] +``` + +## API for both Object and Array + +### set(key, value, target) + +Returns a shallow copy of `target` with its value at index or key `key` set to `value`. + +```javascript +const arr = [1, 2, 5]; +const result = ops.set(2, 3, arr); +console.log(result); +// [1, 2, 3] + +const obj = { + a: 'X', + b: 'theB', +}; +const resultObj = ops.set('a', 'theA', obj); +console.log(resultObj); +// { +// a: 'theA', +// b: 'theB', +// } +``` + +## License + +MIT. See `LICENSE` diff --git a/package.json b/package.json new file mode 100644 index 0000000..43a0605 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "immutable-ops", + "version": "0.1.0", + "description": "A collection of functions to perform immutable operations on plain JavaScript objects", + "main": "lib/index.js", + "scripts": { + "test": "make test", + "prepublish": "make build" + }, + "keywords": [ + ], + "author": "Tommi Kaikkonen ", + "repository": { + "type": "git", + "url": "https://github.com/tommikaikkonen/immutable-ops.git" + }, + "license": "MIT", + "devDependencies": { + "babel": "^5.8.24", + "babel-core": "^5.8.24", + "babel-eslint": "^4.1.5", + "chai": "^3.0.0", + "eslint": "^1.10.1", + "eslint-config-airbnb": "1.0.0", + "mocha": "^2.2.5", + "sinon": "^1.17.2", + "sinon-chai": "^2.8.0" + }, + "dependencies": { + "lodash": "^4.2.1", + "ramda": "^0.19.1" + } +} diff --git a/src/index.js b/src/index.js new file mode 100755 index 0000000..7394d30 --- /dev/null +++ b/src/index.js @@ -0,0 +1,448 @@ +import forOwn from 'lodash/forOwn'; +import isArrayLike from 'lodash/isArrayLike'; +import curry from 'ramda/src/curry'; +import placeholder from 'ramda/src/__'; + +const MUTABILITY_TAG = '@@_______canMutate'; + +export function canMutate(obj) { + return obj.hasOwnProperty(MUTABILITY_TAG); +} + +function addCanMutateTag(opts, obj) { + Object.defineProperty(obj, MUTABILITY_TAG, { + value: true, + configurable: true, + enumerable: false, + }); + + if (typeof opts.addMutated === 'function') { + opts.addMutated(obj); + } + return obj; +} + +function removeCanMutateTag(obj) { + delete obj[MUTABILITY_TAG]; + return obj; +} + +function prepareNewObject(opts, instance) { + if (opts.isWithMutations()) { + addCanMutateTag(opts, instance); + } + return instance; +} + +function forceArray(arg) { + if (!(arg instanceof Array)) { + return [arg]; + } + return arg; +} + +const PATH_SEPARATOR = '.'; + +function normalizePath(pathArg) { + if (typeof pathArg === 'string') { + if (pathArg.indexOf(PATH_SEPARATOR) === -1) { + return [pathArg]; + } + return pathArg.split(PATH_SEPARATOR); + } + + return pathArg; +} + +function mutableSet(opts, key, value, obj) { + obj[key] = value; + return obj; +} + +function mutableSetIn(opts, _pathArg, value, obj) { + const originalPathArg = normalizePath(_pathArg); + + const pathLen = originalPathArg.length; + originalPathArg.reduce((acc, curr, idx) => { + if (idx === pathLen - 1) { + acc[curr] = value; + return value; + } + + const currType = typeof acc[curr]; + + if (currType === 'undefined') { + const newObj = {}; + prepareNewObject(newObj); + acc[curr] = newObj; + return newObj; + } + + if (currType === 'object') { + return acc[curr]; + } + + const pathRepr = `${originalPathArg[idx - 1]}.${curr}`; + throw new Error(`A non-object value was encountered when traversing setIn path at ${pathRepr}.`); + }); + + return obj; +} + +function valueInPath(opts, _pathArg, obj) { + const pathArg = normalizePath(_pathArg); + + let acc = obj; + for (let i = 0; i < pathArg.length; i++) { + const curr = pathArg[i]; + const currRef = acc[curr]; + if (i === pathArg.length - 1) { + return currRef; + } + + if (typeof currRef === 'object') { + acc = currRef; + } else { + return undefined; + } + } +} + +function immutableSetIn(opts, _pathArg, value, obj) { + const pathArg = normalizePath(_pathArg); + + const currentValue = valueInPath(opts, pathArg, obj); + if (value === currentValue) return obj; + + const pathLen = pathArg.length; + let acc = Object.assign(prepareNewObject(opts, {}), obj); + const rootObj = acc; + + pathArg.forEach((curr, idx) => { + if (idx === pathLen - 1) { + acc[curr] = value; + return; + } + + const currRef = acc[curr]; + const currType = typeof currRef; + + if (currType === 'object') { + if (canMutate(currRef)) { + acc = currRef; + } else { + const newObj = prepareNewObject(opts, {}); + acc[curr] = Object.assign(newObj, currRef); + acc = newObj; + } + return; + } + + if (currType === 'undefined') { + const newObj = prepareNewObject(opts, {}); + acc[curr] = newObj; + acc = newObj; + return; + } + + const pathRepr = `${pathArg[idx - 1]}.${curr}`; + throw new Error(`A non-object value was encountered when traversing setIn path at ${pathRepr}.`); + }); + + return rootObj; +} + +function mutableMerge(isDeep, opts, _mergeObjs, baseObj) { + const mergeObjs = forceArray(_mergeObjs); + + if (opts.deep) { + mergeObjs.forEach(mergeObj => { + forOwn(mergeObj, (value, key) => { + if (isDeep && baseObj.hasOwnProperty(key)) { + let assignValue; + if (typeof value === 'object') { + assignValue = canMutate(value) + ? mutableMerge(isDeep, opts, [value], baseObj[key]) + : immutableMerge(isDeep, opts, [value], baseObj[key]); // eslint-disable-line + } else { + assignValue = value; + } + + baseObj[key] = assignValue; + } else { + baseObj[key] = value; + } + }); + }); + } else { + Object.assign(baseObj, ...mergeObjs); + } + + return baseObj; +} + +const mutableShallowMerge = mutableMerge.bind(null, false); +const mutableDeepMerge = mutableMerge.bind(null, true); + +function mutableOmit(opts, _keys, obj) { + const keys = forceArray(_keys); + keys.forEach(key => { + delete obj[key]; + }); + return obj; +} + +function _shouldMergeKey(obj, other, key) { + return obj[key] !== other[key]; +} + +function immutableMerge(isDeep, opts, _mergeObjs, obj) { + if (canMutate(obj)) return mutableMerge(isDeep, opts, _mergeObjs, obj); + const mergeObjs = forceArray(_mergeObjs); + + let hasChanges = false; + let nextObject = obj; + + const willChange = () => { + if (!hasChanges) { + hasChanges = true; + nextObject = Object.assign({}, obj); + prepareNewObject(opts, nextObject); + } + }; + + mergeObjs.forEach(mergeObj => { + forOwn(mergeObj, (mergeValue, key) => { + if (isDeep && obj.hasOwnProperty(key)) { + const currentValue = nextObject[key]; + if (typeof mergeValue === 'object' && !(mergeValue instanceof Array)) { + if (_shouldMergeKey(nextObject, mergeObj, key)) { + const recursiveMergeResult = immutableMerge(isDeep, opts, mergeValue, currentValue); + + if (recursiveMergeResult !== currentValue) { + willChange(); + nextObject[key] = recursiveMergeResult; + } + } + return true; // continue forOwn + } + } + if (_shouldMergeKey(nextObject, mergeObj, key)) { + willChange(); + nextObject[key] = mergeValue; + } + }); + }); + + if (hasChanges) { + addCanMutateTag(opts, nextObject); + } + + return nextObject; +} + +const immutableDeepMerge = immutableMerge.bind(null, true); +const immutableShallowMerge = immutableMerge.bind(null, false); + +function immutableArrSet(opts, index, value, arr) { + if (canMutate(arr)) return mutableSet(opts, index, value, arr); + + const newArr = [...arr.slice(0, index), value, ...arr.slice(index + 1)]; + prepareNewObject(opts, newArr); + + return newArr; +} + +function immutableSet(opts, key, value, obj) { + if (isArrayLike(obj)) return immutableArrSet(opts, key, value, obj); + if (canMutate(obj)) return mutableSet(opts, key, obj); + const newObj = Object.assign({}, obj); + prepareNewObject(newObj); + newObj[key] = value; + return newObj; +} + +function immutableOmit(opts, _keys, obj) { + if (canMutate(obj)) return mutableOmit(opts, _keys, obj); + + const keys = forceArray(_keys); + + const newObj = Object.assign({}, obj); + keys.forEach(key => { + delete newObj[key]; + }); + prepareNewObject(opts, newObj); + return newObj; +} + +function mutableArrPush(opts, _vals, arr) { + const vals = forceArray(_vals); + arr.push(...vals); + return arr; +} + +function mutableArrFilter(opts, func, arr) { + let currIndex = 0; + let originalIndex = 0; + while (currIndex < arr.length) { + const item = arr[currIndex]; + if (!func(item, originalIndex)) { + arr.splice(currIndex, 1); + } else { + currIndex++; + } + originalIndex++; + } + + return arr; +} + +function mutableArrSplice(opts, index, deleteCount, _vals, arr) { + const vals = forceArray(_vals); + arr.splice(index, deleteCount, ...vals); + return arr; +} + +function mutableArrInsert(opts, index, _vals, arr) { + return mutableArrSplice(opts, index, 0, _vals, arr); +} + +function immutableArrSplice(opts, index, deleteCount, _vals, arr) { + if (canMutate(arr)) return mutableArrSplice(opts, index, deleteCount, _vals, arr); + + const vals = forceArray(_vals); + + const newArr = arr.slice(); + prepareNewObject(opts, newArr); + newArr.splice(index, deleteCount, ...vals); + + return newArr; +} + +function immutableArrInsert(opts, index, _vals, arr) { + if (canMutate(arr)) return mutableArrInsert(index, _vals, arr); + return immutableArrSplice(opts, index, 0, _vals, arr); +} + +function immutableArrPush(opts, vals, arr) { + return immutableArrInsert(opts, arr.length, vals, arr); +} + +function immutableArrFilter(opts, func, arr) { + if (canMutate(arr)) return mutableArrFilter(opts, func, arr); + const newArr = arr.filter(func); + prepareNewObject(opts, newArr); + return newArr; +} + +const operations = { + // object operations + merge: immutableShallowMerge, + deepMerge: immutableDeepMerge, + omit: immutableOmit, + setIn: immutableSetIn, + + // array operations + insert: immutableArrInsert, + push: immutableArrPush, + filter: immutableArrFilter, + splice: immutableArrSplice, + + // both + set: immutableSet, + + mutable: { + // object operations + merge: mutableShallowMerge, + deepMerge: mutableDeepMerge, + omit: mutableOmit, + setIn: mutableSetIn, + + // array operations + insert: mutableArrInsert, + push: mutableArrPush, + filter: mutableArrFilter, + splice: mutableArrSplice, + + // both + set: mutableSet, + }, +}; + +function bindOperationsToOptions(opsObj, opts) { + const boundOperations = {}; + + forOwn(opsObj, (value, key) => { + if (typeof value === 'object') { + boundOperations[key] = bindOperationsToOptions(value, opts); + } else { + boundOperations[key] = value.bind(null, opts); + + if (opts.curried) { + boundOperations[key] = curry(boundOperations[key]); + } + } + }); + + return boundOperations; +} + +function getMutationManager() { + const previousSessionStack = []; + let currMutatedObjects = null; + + return { + open() { + if (currMutatedObjects !== null) { + previousSessionStack.push(currMutatedObjects); + } + currMutatedObjects = []; + }, + + isWithMutations() { + return currMutatedObjects !== null; + }, + + addMutated(obj) { + currMutatedObjects.push(obj); + }, + + mutatedObjects() { + return currMutatedObjects; + }, + + close() { + currMutatedObjects.forEach(removeCanMutateTag); + if (previousSessionStack.length) { + currMutatedObjects = previousSessionStack.pop(); + } else { + currMutatedObjects = null; + } + }, + }; +} + +export default function getImmutableOps(userOpts) { + const defaultOpts = { + curried: true, + }; + + const baseOpts = Object.assign({}, defaultOpts, (userOpts || {})); + + const opts = Object.assign({}, baseOpts, getMutationManager()); + + const boundOperations = bindOperationsToOptions(operations, opts); + + boundOperations.batched = func => { + opts.open(); + const returnValue = func(); + opts.close(); + return returnValue; + }; + + boundOperations.mutatedObjects = opts.mutatedObjects; + + boundOperations.__ = placeholder; + + return boundOperations; +} diff --git a/src/test/testOperations.js b/src/test/testOperations.js new file mode 100644 index 0000000..ffcae73 --- /dev/null +++ b/src/test/testOperations.js @@ -0,0 +1,194 @@ +import chai from 'chai'; +import sinonChai from 'sinon-chai'; +import getOps, { canMutate } from '../index'; + +chai.use(sinonChai); +const { expect } = chai; + +describe('operations', () => { + let ops; + + beforeEach(() => { + ops = getOps(); + }); + + describe('object', () => { + describe('batched mutations', () => { + it('deepMerges', () => { + const baseObj = { + change: 'Tommi', + dontChange: 25, + deeper: { + dontChange: 'John', + change: 30, + }, + }; + const mergeObj = { + change: 'None', + add: 'US', + deeper: { + add: 'US', + change: 35, + }, + }; + let result; + const merger = ops.deepMerge(mergeObj); + ops.batched(() => { + result = merger(baseObj); + expect(canMutate(result)).to.be.true; + expect(canMutate(result.deeper)).to.be.true; + }); + + expect(canMutate(result)).to.be.false; + expect(canMutate(result.deeper)).to.be.false; + + expect(result).to.not.equal(baseObj); + + expect(result).to.contain.all.keys(['change', 'dontChange', 'add', 'deeper']); + expect(result.change).to.not.equal(baseObj.change); + expect(result.dontChange).to.equal(baseObj.dontChange); + + expect(result.deeper).to.not.equal(baseObj.deeper); + expect(result.deeper).to.contain.all.keys(['dontChange', 'change', 'add']); + expect(result.deeper.dontChange).to.equal(baseObj.deeper.dontChange); + expect(result.deeper.change).to.not.equal(baseObj.deeper.change); + }); + + it('omits a single key', () => { + const obj = { + name: 'Tommi', + age: 25, + }; + + let result; + const omitter = ops.omit('age'); + + ops.batched(() => { + result = omitter(obj); + expect(canMutate(result)).to.be.true; + }); + + expect(canMutate(result)).to.be.false; + expect(result).to.not.contain.keys(['age']); + }); + + it('omits an array of keys', () => { + const obj = { + name: 'Tommi', + age: 25, + }; + + let result; + + const omitter = ops.omit(['age']); + ops.batched(() => { + result = omitter(obj); + + expect(canMutate(result)).to.be.true; + }); + + expect(canMutate(result)).to.be.false; + expect(result).to.not.contain.keys(['age']); + }); + + it('sets a value in path', () => { + const obj = { + first: { + second: { + value: 'value', + maintain: true, + }, + maintain: true, + }, + maintain: true, + }; + let result; + + const setter = ops.setIn('first.second.value', 'anotherValue'); + + ops.batched(() => { + result = setter(obj); + + expect(canMutate(result)).to.be.true; + }); + + expect(canMutate(result)).to.be.false; + expect(result).not.to.equal(obj); + expect(result.first.second.value).to.equal('anotherValue'); + expect(result.maintain).to.be.true; + expect(result.first.maintain).to.be.true; + expect(result.first.second.maintain).to.be.true; + }); + }); + }); + + describe('array', () =>{ + describe('batched mutations', () => { + it('push', () => { + const push = ops.push; + const arr = [5, 4]; + const pusher = push([1, 2, 3]); + const result = ops.batched(() => pusher(arr)); + + expect(result).to.not.equal(arr); + + expect(result).to.deep.equal([5, 4, 1, 2, 3]); + }); + + it('insert', () => { + const insert = ops.insert; + const arr = [1, 2, 5]; + const inserter = insert(2, [3, 4]); + const result = ops.batched(() => inserter(arr)); + + expect(result).to.deep.equal([1, 2, 3, 4, 5]); + }); + + it('filter', () => { + const arr = [0, 1, 2, 3]; + let result; + + ops.batched(() => { + result = ops.filter(item => item % 2 === 0, arr); + expect(canMutate(result)).to.be.true; + }); + + expect(result).to.deep.equal([0, 2]); + expect(canMutate(result)).to.be.false; + }); + + it('set', () => { + const arr = [1, 2, 987, 4]; + + const result = ops.batched(() => { + const setter = ops.set(2, 3); + const res = setter(arr); + expect(canMutate(res)).to.be.true; + return res; + }); + + expect(canMutate(result)).to.be.false; + expect(result).to.deep.equal([1, 2, 3, 4]); + }); + + it('splice with deletions', () => { + const splice = ops.splice; + const arr = [1, 2, 3, 3, 3, 4]; + const splicer = splice(2, 2, []); + + const result = splicer(arr); + expect(result).to.deep.equal([1, 2, 3, 4]); + }); + + it('splice with additions', () => { + const splice = ops.splice; + const arr = [1, 5]; + const splicer = splice(1, 0, [2, 3, 4]); + + const result = splicer(arr); + + expect(result).to.deep.equal([1, 2, 3, 4, 5]); + }); + }); + }); +});