UNPKG

icepick

Version:

Utilities for treating frozen JavaScript objects as persistent immutable collections.

373 lines (319 loc) 9.32 kB
/** * This allows you to work with object hierarchies that have been frozen * with Object.freeze(). "get" operations can use the normal JS syntax, * but operations that modify the data will have to return partial copies of * the structure. The portions of the structure that did not change will * === their previous values. * * Inspired by clojure/mori and Immutable.js */ 'use strict' const i = exports const identity = coll => coll // we only care about objects or arrays for now const weCareAbout = val => val !== null && (Array.isArray(val) || // This will skip objects created with `new Foo()` // and objects created with `Object.create(proto)` // The benefit is ignoring DOM elements and event emitters, // which are often circular. isObjectLike(val)) const isObjectLike = val => typeof val === 'object' && (val.constructor === Object || val.constructor == null) && (Object.getPrototypeOf(val) === Object.prototype || Object.getPrototypeOf(val) === null) const forKeys = (obj, iter) => { let idx, keys if (Array.isArray(obj)) { idx = obj.length while (idx--) { iter(idx) } return } keys = Object.keys(obj) idx = keys.length while (idx--) { iter(keys[idx]) } } const cloneObj = obj => { const newObj = obj.constructor == null ? Object.create(null) : {} const keys = Object.keys(obj) let idx = keys.length let key while (idx--) { key = keys[idx] newObj[key] = obj[key] } return newObj } const clone = (coll) => { if (Array.isArray(coll)) { return coll.slice() } else { return cloneObj(coll) } } const freezeIfNeeded = process.env.NODE_ENV === 'production' ? identity : coll => { if (weCareAbout(coll) && !Object.isFrozen(coll)) { return baseFreeze(coll) } return coll } const _freeze = process.env.NODE_ENV === 'production' ? identity : coll => { if (typeof coll === 'object') { return Object.freeze(coll) } else { return coll } } const prevNodes = [] const baseFreeze = (coll) => { if (prevNodes.some(val => val === coll)) { throw new Error('object has a reference cycle') } prevNodes.push(coll) forKeys(coll, key => { const prop = coll[key] if (weCareAbout(prop)) { baseFreeze(prop) } }) prevNodes.pop() Object.freeze(coll) return coll } /** * recrursively freeze an object and all its child objects * @param {Object|Array} coll * @return {Object|Array} */ exports.freeze = process.env.NODE_ENV === 'production' ? identity : baseFreeze /** * recursively un-freeze an object, by cloning frozen collections * @param {[type]} coll [description] * @return {[type]} [description] */ exports.thaw = function thaw (coll) { if (!weCareAbout(coll) || !Object.isFrozen(coll)) return coll const newColl = Array.isArray(coll) ? new Array(coll.length) : {} forKeys(coll, key => { newColl[key] = thaw(coll[key]) }) return newColl } /** * set a value on an object or array * @param {Object|Array} coll * @param {String|Number} key Key or index * @param {Object} value * @return {Object|Array} new object hierarchy with modifications */ exports.assoc = function assoc (coll, key, value) { if (coll[key] === value) { return _freeze(coll) } const newObj = clone(coll) newObj[key] = freezeIfNeeded(value) return _freeze(newObj) } exports.set = exports.assoc /** * un-set a value on an object or array * @param {Object|Array} coll * @param {String|Number} key Key or Index * @return {Object|Array} New object or array */ exports.dissoc = function dissoc (coll, key) { const newObj = clone(coll) delete newObj[key] return _freeze(newObj) } exports.unset = exports.dissoc /** * set a value deep in a hierarchical structure * @param {Object|Array} coll * @param {Array} path A list of keys to traverse * @param {Object} value * @return {Object|Array} new object hierarchy with modifications */ exports.assocIn = function assocIn (coll, path, value) { const key0 = path[0] if (path.length === 1) { // simplest case is a 1-element array. Just a simple assoc. return i.assoc(coll, key0, value) } else { // break the problem down. Assoc this object with the first key // and the result of assocIn with the rest of the keys return i.assoc(coll, key0, assocIn(coll[key0] || {}, path.slice(1), value)) } } exports.setIn = exports.assocIn /** * un-set a value on an object or array * @param {Object|Array} coll * @param {Array} path A list of keys to traverse * @return {Object|Array} New object or array */ exports.dissocIn = function dissocIn (coll, path) { const key0 = path[0] if (!coll.hasOwnProperty(key0)) { return coll } if (path.length === 1) { // simplest case is a 1-element array. Just a simple dissoc. return i.dissoc(coll, key0) } else { // break the problem down. Assoc this object with the first key // and the result of dissocIn with the rest of the keys return i.assoc(coll, key0, dissocIn(coll[key0], path.slice(1))) } } exports.unsetIn = exports.dissocIn /** * get an object from a hierachy based on an array of keys * @param {Object|Array} coll * @param {Array} path list of keys * @return {Object} value, or undefined */ function baseGet (coll, path) { return (path || []).reduce((curr, key) => { if (!curr) { return } return curr[key] }, coll) } exports.getIn = baseGet /** * Update a value in a hierarchy * @param {Object|Array} coll * @param {Array} path list of keys * @param {Function} callback The existing value with be passed to this. * Return the new value to set * @return {Object|Array} new object hierarchy with modifications */ exports.updateIn = function updateIn (coll, path, callback) { const existingVal = baseGet(coll, path) return i.assocIn(coll, path, callback(existingVal)) }; // generate wrappers for the mutative array methods ['push', 'unshift', 'pop', 'shift', 'reverse', 'sort'] .forEach((methodName) => { exports[methodName] = function (arr, val) { const newArr = [...arr] newArr[methodName](freezeIfNeeded(val)) return _freeze(newArr) } exports[methodName].displayName = 'icepick.' + methodName }) // splice is special because it is variadic exports.splice = function splice (arr, ..._args) { const newArr = [...arr] const args = _args.map(freezeIfNeeded) newArr.splice.apply(newArr, args) return _freeze(newArr) } // slice is non-mutative exports.slice = function slice (arr, arg1, arg2) { const newArr = arr.slice(arg1, arg2) return _freeze(newArr) }; ['map', 'filter'].forEach((methodName) => { exports[methodName] = function (fn, arr) { const newArr = arr[methodName](fn) return _freeze(newArr) } exports[methodName].displayName = 'icepick.' + methodName }) exports.extend = exports.assign = function assign (obj, ...objs) { const newObj = objs.reduce(singleAssign, obj) return _freeze(newObj) } function singleAssign (obj1, obj2) { return Object.keys(obj2).reduce((obj, key) => { return i.assoc(obj, key, obj2[key]) }, obj1) } exports.merge = merge function merge (target, source, resolver) { if (target == null || source == null) { return target } return Object.keys(source).reduce((obj, key) => { const sourceVal = source[key] const targetVal = obj[key] const resolvedSourceVal = resolver ? resolver(targetVal, sourceVal, key) : sourceVal if (weCareAbout(sourceVal) && weCareAbout(targetVal)) { // if they are both frozen and reference equal, assume they are deep equal if ( resolvedSourceVal === targetVal && ( process.env.NODE_ENV === 'production' || ( Object.isFrozen(resolvedSourceVal) && Object.isFrozen(targetVal) ) ) ) { return obj } if (Array.isArray(sourceVal)) { return i.assoc(obj, key, resolvedSourceVal) } // recursively merge pairs of objects return assocIfDifferent(obj, key, merge(targetVal, resolvedSourceVal, resolver)) } // primitive values, stuff with prototypes return assocIfDifferent(obj, key, resolvedSourceVal) }, target) } function assocIfDifferent (target, key, value) { if (target[key] === value) { return target } return i.assoc(target, key, value) } const chainProto = { value: function value () { return this.val }, thru: function thru (fn) { this.val = freezeIfNeeded(fn(this.val)) return this } } Object.keys(exports).forEach((methodName) => { if (methodName.match(/^(map|filter)$/)) { chainProto[methodName] = function (fn) { this.val = exports[methodName](fn, this.val) return this } return } chainProto[methodName] = function (...args) { this.val = exports[methodName](this.val, ...args) return this } }) exports.chain = function chain (val) { const wrapped = Object.create(chainProto) wrapped.val = val return wrapped } // for testing if (process.env.NODE_ENV !== 'development' && process.env.NODE_ENV !== 'production') { exports._weCareAbout = weCareAbout }