// external dependencies import hashIt from 'hash-it'; import every from 'lodash/every'; import fill from 'lodash/fill'; import find from 'lodash/find'; import findKey from 'lodash/findKey'; import findLastKey from 'lodash/findLastKey'; import isArray from 'lodash/isArray'; import isUndefined from 'lodash/isUndefined'; import filter from 'lodash/filter'; import forEach from 'lodash/forEach'; import get from 'lodash/fp/get'; import map from 'lodash/map'; import merge from 'lodash/merge'; import reduce from 'lodash/reduce'; import reduceRight from 'lodash/reduceRight'; import set from 'lodash/fp/set'; import slice from 'lodash/slice'; import some from 'lodash/some'; import toPairs from 'lodash/toPairs'; import values from 'lodash/values'; // constants import { CRIO_ARRAY_TYPE, CRIO_OBJECT_TYPE, CRIO_SYMBOL, CRIO_TYPE, ITERATOR_PROPERTY_DESCRIPTOR, UNSCOPABLES_PROPERTY_DESCRIPTOR } from './constants'; // utils import { createAssignToObject, freeze, getCorrectConstructor, getCrioValue, getKeysMetadata, getRelativeValue, getStandardValue, hasOwnProperty, isCrio, isCrioArray, isEqual, keys, stringify } from './utils'; /** * @module Crio */ let assignToObject; /** * @class Crio * @classdesc base crio class * * @memberof module:Crio */ export class Crio { /** * @function constructor * * @description * add the items to the crio, and return a frozen version * * @param {Object} object object passed for crioing * @returns {Crio} crioed object */ constructor(object) { forEach(object, assignToObject(this, getCrioValue)); return freeze(this); } /** * @static */ static get ['@@species']() { return this; } get [CRIO_SYMBOL]() { return true; } get hashCode() { return hashIt(this); } /** * @function clear * @memberof! Crio# * * @description * get a new crio that is empty * * @returns {Crio} new empty crio instance */ clear() { return new this.constructor(this.isArray() ? [] : {}); } /** * @function compact * * @description * remove all falsy values from the crio * * @returns {Crio} new crio instance */ compact() { return this.filter((value) => { return !!value; }); } /** * @function delete * * @description * remove an item from the crio * * @param {number|string} key the key to remove from the crio * @returns {Crio} new crio instance with item removed */ delete(key) { const {[`${key}`]: deletedIgnored, ...updated} = this; return new this.constructor(this.isArray() ? values(updated) : updated); } /** * @function deleteIn * * @description * remove a nested item from the crio * * @param {Array<number|string>} keys the path of the item to remove * @returns {Crio} new crio instance with the item removed */ deleteIn(keys) { if (!keys || !keys.length) { return this; } if (keys.length === 1) { return this.delete(keys[0]); } const {currentValue, lastIndex, parentKeys} = getKeysMetadata(keys, this); if (!isCrio(currentValue)) { return this; } return this.setIn(parentKeys, currentValue.delete(keys[lastIndex])); } /** * @function entries * * @description * get the pairs of [key, value] in the crio * * @returns {Array<Array<string>>} [key, value] pairs */ entries() { return toPairs(this); } /** * @function equals * * @description * does the object passed equal the crio * * @param {*} object object to compare against the instance * @returns {boolean} is the object equal */ equals(object) { return isEqual(this, object); } /** * @function every * * @description * does every instance in the crio match * * @param {function} fn the function to test for matching * @param {*} [thisArg=this] argument for "this" to use in the iteration * @returns {boolean} does every instance match */ every(fn, thisArg = this) { return every(this, fn, thisArg); } /** * @function filter * * @description * get a reduced set from the crio * * @param {function} fn function to test for if it should be returned or not * @param {*} [thisArg=this] argument for "this" to use in the iteration * @returns {Crio} new crio instance */ filter(fn, thisArg = this) { const updated = this.isArray() ? filter(this, fn, thisArg) : reduce( this, (updatedValue, value, key) => { if (fn.call(thisArg, value, key, this)) { updatedValue[key] = value; } return updatedValue; }, {} ); return new this.constructor(updated); } /** * @function find * * @description * find an item in the crio if it exists * * @param {function} fn function to test for finding the item * @param {number} [fromKey] key to start from when performing the find * @returns {*} found item or undefined */ find(fn, fromKey) { return find(this, fn, fromKey); } /** * @function forEach * * @description * iterate over the crio calling fn * * @param {function} fn function to call in iteration * @param {*} [thisArg=this] argument to use as "this" in the iteration * @returns {Crio} new crio instance */ forEach(fn, thisArg = this) { forEach(this, fn, thisArg); return this; } /** * @function get * * @description * get the item at key passed * * @param {number|string} key key to retrieve * @returns {*} item found at key */ get(key) { return this[key]; } /** * @function getIn * * @description * get the nested item at the path passed * * @param {Array<number|string>} keys path to retrieve from * @returns {*} item found at nested path */ getIn(keys) { return keys && keys.length ? get(keys, this) : this; } /** * @function has * * @description * does the crio have the key passed * * @param {number|string} key key to test * @returns {boolean} does the crio have the key */ has(key) { return hasOwnProperty.call(this, key); } /** * @function hasIn * * @description * does the crio have the nested key at the path passed * * @param {Array<number|string>} keys path to test * @returns {boolean} does the crio have the nested path */ hasIn(keys) { if (!keys || !keys.length) { return false; } if (keys.length === 1) { return this.has(keys[0]); } const {currentValue, lastIndex} = getKeysMetadata(keys, this); return isCrio(currentValue) && currentValue.has(keys[lastIndex]); } /** * @function includes * * @description * does the crio have the value passed * * @param {*} value value to test for existence * @returns {boolean} does the value exist in the crio */ includes(value) { return this.some((currentValue) => { return currentValue === value; }); } /** * @function isArray * * @description * is the crio an array * * @returns {boolean} is the crio an array */ isArray() { return this[CRIO_TYPE] === CRIO_ARRAY_TYPE; } /** * @function isObject * * @description * is the crio an object * * @returns {boolean} is the crio an object */ isObject() { return this[CRIO_TYPE] === CRIO_OBJECT_TYPE; } /** * @function keys * * @description * get the keys of the crio * * @returns {Array<string>} keys in the crio */ keys() { return keys(this); } /** * @function map * * @description * iterate over the crio mapping the result of fn to the key * * @param {function} fn function to call on iteration * @param {*} [thisArg=this] argument to use as "this" in the iteration * @returns {Crio} new crio instance */ map(fn, thisArg = this) { const updated = this.isArray() ? map(this, fn, thisArg) : reduce( this, (updatedValue, value, key) => { updatedValue[key] = fn.call(thisArg, value, key, this); return updatedValue; }, {} ); return new this.constructor(updated); } /** * @function merge * * @description * merge objects with crio * * @param {...Array<CrioArray|CrioObject|Object>} objects objects to merge with the crio * @returns {Crio} new crio instance */ merge(...objects) { if (!objects.length) { return this; } return new this.constructor(merge({}, this.thaw(), ...objects)); } /** * @function mergeIn * * @description * merge the objects passed at the nested path in the crioArray * * @param {Array<number|string>} keys path to merge into * @param {...Array<CrioArray|CrioObject|Object>} objects objects to merge with the crio * @returns {Crio} new crio instance */ mergeIn(keys, ...objects) { if (!keys || !keys.length) { return this; } const valueToMerge = this.getIn(keys); if (!isCrio(valueToMerge)) { return this.setIn(keys, merge({}, ...objects)); } return new this.constructor( this.setIn(keys, valueToMerge.merge(...objects)) ); } /** * @function mutate * * @description * work with the object in a mutated way and return the crioed result of that call * * @param {function} fn function to apply to crio * @param {*} [thisArg=this] argument to use for "this" in the call * @returns {*} crioed value resulting from the call */ mutate(fn, thisArg = this) { const result = fn.call(thisArg, this.thaw(), this); return getCrioValue( result, getCorrectConstructor(result, CrioArray, CrioObject) ); } /** * @function pluck * * @description * get the values in each object in the collection at key * * @param {string} key key to find value of in collection object * @returns {Crio} new crio instance */ pluck(key) { return this.reduce((pluckedValues, value) => { pluckedValues.push( value && hasOwnProperty.call(value, key) ? value[key] : undefined ); return pluckedValues; }, []); } /** * @function pluckIn * * @description * get the values in each object in the collection at the nested path * * @param {Array<number|string>} keys keys to find value of in collection object * @returns {Crio} new crio instance */ pluckIn(keys) { if (!keys || !keys.length) { return new CrioArray([]); } if (keys.length === 1) { return this.pluck(keys[0]); } const {currentValue, lastIndex} = getKeysMetadata(keys, this); return isCrio(currentValue) ? currentValue.pluck(keys[lastIndex]) : this; } /** * @function reduce * * @description * reduce the crio down to a single value, starting with initial value * * @param {function} fn the function to iterate with * @param {*} initialValue the initial value of the reduction * @param {*} [thisArg=this] argument to use for "this" in the call of fn * @returns {*} the reduced value */ reduce(fn, initialValue, thisArg = this) { const reducedValue = reduce.call(thisArg, this, fn, initialValue); return getCrioValue( reducedValue, getCorrectConstructor(reducedValue, CrioArray, CrioObject) ); } /** * @function reduceRight * * @description * reduce the crio down to a single value, starting with initial value, in reverse order * * @param {function} fn the function to iterate with * @param {*} initialValue the initial value of the reduction * @param {*} [thisArg=this] argument to use for "this" in the call of fn * @returns {*} the reduced value */ reduceRight(fn, initialValue, thisArg = this) { const reducedValue = reduceRight.call(thisArg, this, fn, initialValue); return getCrioValue( reducedValue, getCorrectConstructor(reducedValue, CrioArray, CrioObject) ); } /** * @function set * * @description * set the value at the key passed * * @param {number|string} key key to assign value to * @param {*} value value to assign * @returns {Crio} new crio instance */ set(key, value) { return new this.constructor({ ...this, [key]: value }); } /** * @function setIn * * @description * deeply set the value at the path passed * * @param {Array<number|string>} keys path to assign value to * @param {*} value value to assign * @returns {Crio} new crio instance */ setIn(keys, value) { return keys && keys.length ? new this.constructor(set(keys, value, this)) : this; } /** * @function some * * @description * do any of the items in crio match per the fn passed * * @param {function} fn fn to iterate with * @param {*} [thisArg=this] argument to use as "this" in the iteration * @returns {boolean} are there any matches */ some(fn, thisArg = this) { return some(this, fn, thisArg); } /** * @function thaw * * @description * create a plain JS version of the crio * * @returns {Array<*>|Object} plain JS version of crio */ thaw() { const returnValue = this.isArray() ? [] : {}; forEach(this, assignToObject(returnValue, getStandardValue)); return returnValue; } /** * @function toArray * * @description * convert the crio to an array if it isnt already * * @returns {CrioArray} new crio array instance */ toArray() { return this.isArray() ? this : new CrioArray(values(this)); } /** * @function toLocaleString * * @description * convert the crio to stringified form * * @returns {string} stringified crio */ toLocaleString() { return this.toString(); } /** * @function toObject * * @description * convert the crio to an object if it isnt already * * @returns {CrioObject} new crio object instance */ toObject() { return this.isObject() ? this : new CrioObject( reduce( this, (object, value, key) => { object[key] = value; return object; }, {} ) ); } /** * @function toLocaleString * * @description * convert the crio to stringified form * * @returns {string} stringified crio */ toString() { return stringify(this); } /** * @function valueOf * * @description * noop for valueOf * * @returns {Crio} the same crio instance */ valueOf() { return this; } /** * @function values * * @description * get the values of the crio as an array * * @returns {Array<*>} values in the crio */ values() { return values(this); } } Object.defineProperties(Crio.prototype, { [Symbol.iterator]: ITERATOR_PROPERTY_DESCRIPTOR, [Symbol.unscopables]: UNSCOPABLES_PROPERTY_DESCRIPTOR }); /** * @class CrioArray * @classdesc extension of Crio class specific to arrays * * @memberof module:Crio */ export class CrioArray extends Crio { get length() { return keys(this).length; } /** * @function concat * * @description * append the items passed to the crio * * @param {...Array<*>} items items to append to the crio * @returns {CrioArray} new crio array instance */ concat(items) { const concatted = [...values(this), ...items]; return new CrioArray(concatted); } /** * @function copyWithin * * @description * move values around within the array * * @param {number} target target to copy * @param {number} [start=0] index to start copying to * @param {number} [end=this.length] index to stop copying to * @returns {CrioArray} new crio array instance */ copyWithin(target, start = 0, end = this.length) { const copiedArray = values(this); const length = this.length >>> 0; let to = getRelativeValue(target >> 0, length), from = getRelativeValue(start >> 0, length); const final = getRelativeValue(end >> 0, length); let count = Math.min(final - from, length - to), direction = 1; if (from < to && to < from + count) { direction = -1; from += count - 1; to += count - 1; } while (count > 0) { if (from in copiedArray) { copiedArray[to] = copiedArray[from]; } else { delete copiedArray[to]; } from += direction; to += direction; count--; } return new CrioArray(copiedArray); } /** * @function difference * * @description * find the values in this that do not exist in any of the arrays passed * * @param {Array<Array>} arrays arrays to get the difference of * @returns {CrioArray} array of items matching diffference criteria */ difference(...arrays) { if (!arrays.length) { return this; } let indexOfValue; const difference = reduce( arrays, (differenceArray, array) => { if (isArray(array) || isCrioArray(array)) { forEach(array, (value) => { indexOfValue = differenceArray.indexOf(value); if (!!~indexOfValue) { differenceArray.splice(indexOfValue, 1); } }); } return differenceArray; }, this.isArray ? values(this) : {...this} ); return new CrioArray(difference); } /** * @function fill * * @description * fill the array at certain indices with the value passed * * @param {*} value the value to fill the indices with * @param {number} [start=0] the starting index to fill * @param {number} [end=this.length] the ending index to fill * @returns {CrioArray} new crio array instance */ fill(value, start = 0, end = this.length) { const filled = fill(values(this), value, start, end); return new CrioArray(filled, this); } /** * @function findIndex * * @description * find the matching index based on truthy return from fn * * @param {function} fn function to use for test in iteration * @param {*} [thisArg=this] argument to use as "this" in fn call * @returns {number} index of match, or -1 */ findIndex(fn, thisArg = this) { const index = findKey(this, (value, key) => { return fn.call(thisArg, value, +key, this); }); return isUndefined(index) ? -1 : +index; } /** * @function findLastIndex * * @description * find the matching index based on truthy return from fn starting from end * * @param {function} fn function to use for test in iteration * @param {*} [thisArg=this] argument to use as "this" in fn call * @returns {number} index of match, or -1 */ findLastIndex(fn, thisArg = this) { const index = findLastKey(this, (value, key) => { return fn.call(thisArg, value, +key, this); }); return isUndefined(index) ? -1 : +index; } /** * @function first * * @description * take the first n number of items in the array * * @param {number} [size=1] size of elements to take from beginning of array * @returns {CrioArray} */ first(size = 1) { return this.slice(0, size); } /** * @function indexOf * * @description * get the index of the value passed * * @param {*} value value to find in crio * @returns {number} index of match, or -1 */ indexOf(value) { return this.findIndex((thisValue) => { return thisValue === value; }); } /** * @function intersection * * @description * find the values in that exist in this and each of the arrays passed * * @param {Array<Array>} arrays * @returns {CrioArray} */ intersection(...arrays) { if (!arrays.length) { return this; } const allArrays = [this, ...arrays]; const allArraysLength = allArrays.length; let indices = [], indexOfValue; const reducedArrays = reduce( allArrays, (values, array) => { if (isArray(array) || isCrioArray(array)) { forEach(array, (value) => { indexOfValue = values.indexOf(value); if (!!~indexOfValue) { indices[indexOfValue]++; } else { indices[values.length] = 1; values.push(value); } }); } return values; }, [] ); const filteredArrays = filter(reducedArrays, (value, index) => { return indices[index] === allArraysLength; }); return new CrioArray(filteredArrays); } /** * @function join * * @description * join the values in the crio as a string, combined with separator * * @param {string} [separator=','] character(s) to place between strings in combination * @returns {string} parameters joined by separator in string */ join(separator = ',') { return this.thaw().join(separator); } /** * @function last * * @description * take the last n number of items in the array * * @param {number} [size=1] size of elements to take from end of array * @returns {CrioArray} */ last(size = 1) { return this.slice(this.length - size); } /** * @function lastIndexOf * * @description * get the last index of the value passed * * @param {*} value value to find in crio * @returns {number} index of match, or -1 */ lastIndexOf(value) { return this.findLastIndex((thisValue) => { return thisValue === value; }); } /** * @function pop * * @description * get crio based on current crio with last item removed * * @returns {CrioArray} new crio array instance */ pop() { return this.slice(0, this.length - 1); } /** * @function push * * @description * push one to many items to the current crio * * @param {...Array<*>} items the items to add to the array * @returns {CrioArray} the new crio array instance */ push(...items) { return this.concat(items); } /** * @function reverse * * @description * get the same values, but in reverse order * * @returns {CrioArray} new crio array instance */ reverse() { let reversed = values(this); reversed.reverse(); return new CrioArray(reversed); } /** * @function shift * * @description * get crio based on current crio with first item removed * * @returns {CrioArray} new crio array instance */ shift() { return this.slice(1); } /** * @function slice * * @description * get a new crio array based on a subset of the current crio * * @param {number} [start=0] first index to include * @param {number} [end=this.length] size of array from first index * @returns {CrioArray} new crio array instance */ slice(start = 0, end = this.length) { const sliced = slice(this, start, end); return new CrioArray(sliced); } /** * @function sort * * @description * sort the collection by the fn passed * * @param {function} fn the function to sort based on * @returns {CrioArray} new crio array instance */ sort(fn) { let sorted = values(this); sorted.sort(fn); return new CrioArray(sorted); } /** * @function splice * * @description * splice the values into or out of the array * * @param {number} [start=0] starting index to splice * @param {number} [deleteCount=1] length from starting index to removes * @param {...Array<*>} items items to insert after delete is complete * @returns {CrioArray} new crio array instance */ splice(start = 0, deleteCount = 1, ...items) { let spliced = values(this); spliced.splice(start, deleteCount, ...items); return new CrioArray(spliced); } /** * @function unique * * @description * return the current CrioArray with the duplicate values removed * * @returns {CrioArray} new crio instance */ unique() { let hashArray = [], newArray = [], hasHashCode = false, hashCode, storeValue; return this.filter((value) => { hashCode = !!value ? value.hashCode : undefined; hasHashCode = !isUndefined(hashCode); storeValue = !~newArray.indexOf(value) && (!hasHashCode || !~hashArray.indexOf(hashCode)); if (storeValue) { newArray.push(value); if (hasHashCode) { hashArray.push(hashCode); } } return storeValue; }); } /** * @function unshift * * @description * add items passed to the beginning of the crio array * * @param {...Array<*>} items items to prepend to the array * @returns {CrioArray} new crio array instance */ unshift(...items) { return items.length ? new CrioArray([...items, ...values(this)]) : this; } /** * @function xor * * @description * find the values that are the symmetric difference of this and the arrays passed * * @param {Array<Array>} arrays arrays to find symmetric values in * @returns {CrioArray} new crio array instance */ xor(...arrays) { if (!arrays.length) { return this; } const allArrays = [this, ...arrays]; let indicesToRemove = [], indexOfValue; const reducedValues = reduce( allArrays, (values, array) => { if (isArray(array) || isCrioArray(array)) { forEach(array, (value) => { indexOfValue = values.indexOf(value); if (!!~indexOfValue) { indicesToRemove.push(indexOfValue); } else { values.push(value); } }); } return values; }, [] ); const xorValues = filter(reducedValues, (value, index) => { return !~indicesToRemove.indexOf(index); }); return new CrioArray(xorValues); } } Object.defineProperties(CrioArray.prototype, { [CRIO_TYPE]: { configurable: false, enumerable: false, value: CRIO_ARRAY_TYPE, writable: false } }); /** * @class CrioObject * @classdesc extension of Crio class specific to objects * * @memberof module:Crio */ export class CrioObject extends Crio { get size() { return keys(this).length; } /** * @function findKey * * @description * find a specific key based on a matching function * * @param {function} fn function to match * @returns {string|undefined} key matching fn */ findKey(fn) { return findKey(this, fn); } /** * @function findLastKey * * @description * find a specific key based on a matching function, starting from the end * * @param {function} fn function to match * @returns {string|undefined} key matching fn */ findLastKey(fn) { return findLastKey(this, fn); } } Object.defineProperties(CrioObject.prototype, { [CRIO_TYPE]: { configurable: false, enumerable: false, value: CRIO_OBJECT_TYPE, writable: false } }); assignToObject = createAssignToObject(CrioArray, CrioObject);