objtools
Version:
Various utility functions for working with object, including object merging, inheritance, deep copying, etc.
759 lines • 25.6 kB
JavaScript
import _ from 'lodash';
import { createHash } from 'crypto';
export { ObjectMask } from './object-mask.js';
/**
* General utility functions for manipulating object.
*
* @class objtools
*/
/**
* Determines whether a value is considered a scalar or an object. Currently,
* primitives plus Date types plus undefined and null plus functions are considered scalar.
*
* @method isScalar
* @static
* @param {Mixed} value - Value to check
* @return {Boolean}
*/
export function isScalar(value) {
return typeof value !== 'object' || (value instanceof Date) || !value || typeof value === 'function';
}
/**
* Returns true if the given value is considered a terminal value for traversal operations.
* Terminal values are as follows:
* - Any scalars (anything isScalar() returns true for)
* - Any non-plain objects, other than arrays
*
* @method isTerminal
* @param {Mixed} value
* @return {Boolean}
*/
export function isTerminal(value) {
if (typeof value !== 'object')
return true;
if (value === null)
return true;
if (Array.isArray(value))
return false;
let proto = Object.getPrototypeOf(value);
return proto !== Object.prototype && proto !== null;
}
/**
* Checks for deep equality between two object or values.
*
* @method deepEquals
* @static
* @param {Mixed} a
* @param {Mixed} b
* @return {Boolean}
*/
export function deepEquals(a, b) {
if (isScalar(a) && isScalar(b)) {
return scalarEquals(a, b);
}
if (a === null || b === null || a === undefined || b === undefined)
return a === b;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (!deepEquals(a[i], b[i]))
return false;
}
return true;
}
else if (!Array.isArray(a) && !Array.isArray(b)) {
for (let key in a) {
if (!deepEquals(a[key], b[key]))
return false;
}
for (let key in b) {
if (!deepEquals(a[key], b[key]))
return false;
}
return true;
}
else {
return false;
}
}
/**
* Checks whether two scalar values (as determined by isScalar()) are equal.
*
* @method scalarEquals
* @static
* @param {Mixed} a1 - First value
* @param {Mixed} a2 - Second value
* @return {Boolean}
*/
export function scalarEquals(a1, a2) {
if (a1 instanceof Date && a2 instanceof Date)
return (a1.getTime() === a2.getTime());
return a1 === a2;
}
/**
* Returns a deep copy of the given value such that entities are not passed
* by reference.
*
* @method deepCopy
* @static
* @param {Mixed} obj - The object or value to copy
* @return {Mixed}
*/
export function deepCopy(obj) {
let res;
if (isTerminal(obj)) {
res = obj;
}
else if (Array.isArray(obj)) {
res = Array(obj.length);
for (let i = 0; i < obj.length; i++) {
res[i] = deepCopy(obj[i]);
}
}
else {
res = {};
for (let key in obj) {
res[key] = deepCopy(obj[key]);
}
}
return res;
}
/**
* Given an object, converts it into a one-level-deep object where the keys are dot-separated
* paths and the values are the values at those paths.
*
* @method collapseToDotted
* @static
* @param {Object} obj - The object to convert
* @param {Boolean} [includeRedundantLevels] - If set to true, the returned object also includes
* keys for internal objects. By default, an object such as { foo: { bar: "baz"} } will be converted
* into { "foo.bar": "baz" }. If includeRedundantLevels is set, it will instead be converted
* into { "foo": { bar: "baz" }, "foo.bar": "baz" } .
* @param {Boolean} [stopAtArrays] - If set to true, the collapsing function will not descend into
* arrays.
* @example
* By default, an object such as { foo: [ "bar", "baz" ] } is converted
* into { "foo.0": "bar", "foo.1": "baz" }. If stopAtArrays is set, this will instead be converted
* into { "foo": [ "bar", "baz" ] } .
* @return {Object} - The result
*/
export function collapseToDotted(obj, includeRedundantLevels = false, stopAtArrays = false) {
let result = {};
if (isScalar(obj))
return {};
function addObj(obj, path) {
if (isScalar(obj) || (Array.isArray(obj) && stopAtArrays)) {
result[path] = obj;
return;
}
if (includeRedundantLevels) {
result[path] = obj;
}
for (let key in obj) {
addObj(obj[key], path ? (path + '.' + key) : key);
}
}
addObj(obj, '');
delete result[''];
return result;
}
/**
* Returns whether or not the given query fields (in dotted notation) match the document
* (also in dotted notation). The "queries" here are simple equality matches.
*
* @method matchDottedObject
* @static
* @param {Object} doc - The document to test
* @param {Object} query - A one-layer-deep set of key/values to check doc for
* @return {Boolean} - Whether or not the doc matches
*/
export function matchDottedObject(doc, query) {
if (query === true)
return doc === true;
if (isScalar(query) || isScalar(doc))
return deepEquals(query, doc);
for (let queryKey in query) {
if (!deepEquals(doc[queryKey], query[queryKey])) {
return false;
}
}
return true;
}
/**
* Same as matchDottedObject, but for non-dotted objects and queries. Deprecated, as it is
* equivalent to lodash.isMatch(), which we delegate to.
*
* @method matchObject
* @static
* @deprecated
* @param {Object} doc - Object to match against, in structured (not dotted) form
* @param {Object} query - Set of fields (in structed form) to match
* @return {Boolean} - Whether or not the object matches
*/
export function matchObject(doc, query) {
return _.isMatch(doc, query);
}
// For syncObject
const unchangedSymbol = Symbol();
export function syncObject(toObj, fromObj, options = {}) {
// Returns the value that should be written to toVal's parent in place of toVal, or returns unchangedSymbol
// if toVal is not changed.
function syncSubObject(toVal, fromVal, parentObj, path) {
let newVal;
if (parentObj && options.onField && options.onField(path, toVal, fromVal, parentObj) === false) {
return unchangedSymbol;
}
if (isScalar(toVal) && isScalar(fromVal)) {
// Replace scalar value if it isn't identical
if (!scalarEquals(fromVal, toVal)) {
return fromVal;
}
return unchangedSymbol;
}
else if (isScalar(toVal) || isScalar(fromVal)) {
// Exactly one of the two is scalar; either way, overwrite the key on toVal
return fromVal;
}
else if (Array.isArray(toVal) && Array.isArray(fromVal)) {
// Sync each array element individually. If array lengths are the same, sync in place; otherwise,
// sync to new array.
if (toVal.length === fromVal.length) {
for (let i = 0; i < toVal.length; i++) {
let elemPath = path ? (path + '.' + i) : String(i);
newVal = syncSubObject(toVal[i], fromVal[i], toVal, elemPath);
if (newVal !== unchangedSymbol) {
if (options.onChange) {
options.onChange(elemPath, toVal[i], fromVal[i], toVal);
}
toVal[i] = newVal;
}
}
return unchangedSymbol;
}
else {
// Must make new array and sync into it
let newArr = [];
for (let i = 0; i < fromVal.length; i++) {
let elemPath = path ? (path + '.' + i) : String(i);
newVal = syncSubObject(toVal[i], fromVal[i], toVal, elemPath);
if (newVal !== unchangedSymbol || i >= toVal.length) {
if (options.onChange) {
options.onChange(elemPath, toVal[i], fromVal[i], toVal);
}
}
newArr.push(fromVal[i]);
}
return newArr;
}
}
else if (typeof toVal === 'object' && typeof fromVal === 'object') {
// Iterate over keys of each object
for (let key in fromVal) {
let subPath = path ? (path + '.' + key) : key;
if (!fromVal.hasOwnProperty(key))
continue;
if (!toVal.hasOwnProperty(key)) {
// Brand new field for toVal
if (options.onField && options.onField(subPath, toVal[key], fromVal[key], toVal) === false) {
continue;
}
if (options.onChange) {
options.onChange(subPath, toVal[key], fromVal[key], toVal);
}
toVal[key] = fromVal[key];
}
else {
// Replace if different
newVal = syncSubObject(toVal[key], fromVal[key], toVal, subPath);
if (newVal !== unchangedSymbol) {
if (options.onChange) {
options.onChange(subPath, toVal[key], fromVal[key], toVal);
}
toVal[key] = newVal;
}
}
}
// Now iterate over keys of toVal to find fields that should be deleted
for (let key in toVal) {
if (!toVal.hasOwnProperty(key) || fromVal.hasOwnProperty(key))
continue;
let subPath = path ? (path + '.' + key) : key;
if (options.onField && options.onField(subPath, toVal[key], fromVal[key], toVal) === false) {
continue;
}
if (options.onChange) {
options.onChange(subPath, toVal[key], fromVal[key], toVal);
}
delete toVal[key];
}
return unchangedSymbol;
}
else {
// Type mismatch between toVal and fromVal; always replace
return fromVal;
}
}
syncSubObject(toObj, fromObj, null, '');
return toObj;
}
/**
* Sets the value at a given path in an object.
*
* @method setPath
* @static
* @param {Object} obj - The object
* @param {String} path - The path, dot-separated
* @param {Mixed} value - Value to set
* @return {Object} - The same object
*/
export function setPath(obj, path, value) {
let cur = obj;
let parts = path.split('.');
for (let i = 0; i < parts.length; i++) {
if (i === parts.length - 1) {
cur[parts[i]] = value;
}
else {
if (isScalar(cur[parts[i]]))
cur[parts[i]] = {};
cur = cur[parts[i]];
}
}
return obj;
}
/**
* Deletes the value at a given path in an object.
*
* @method deletePath
* @static
* @param {Object} obj
* @param {String} path
* @return {Object} - The object that was passed in
*/
export function deletePath(obj, path) {
let cur = obj;
let parts = path.split('.');
for (let i = 0; i < parts.length; i++) {
if (i === parts.length - 1) {
delete cur[parts[i]];
}
else {
if (isScalar(cur[parts[i]])) {
return obj;
}
cur = cur[parts[i]];
}
}
return obj;
}
/**
* Gets the value at a given path in an object.
*
* @method getPath
* @static
* @param {Object} obj - The object
* @param {String} path - The path, dot-separated
* @param {Boolean} allowSkipArrays - If true: If a field in an object is an array and the
* path key is non-numeric, and the array has exactly 1 element, then the first element
* of the array is used.
* @return {Mixed} - The value at the path
*/
export function getPath(obj, path, allowSkipArrays = false) {
if (path === null || path === undefined)
return obj;
let cur = obj;
let parts = path.split('.');
for (let i = 0; i < parts.length; i++) {
if (isScalar(cur))
return undefined;
if (Array.isArray(cur) && allowSkipArrays && !(/^[0-9]+$/.test(parts[i])) && cur.length === 1) {
cur = cur[0];
i--;
}
else {
cur = cur[parts[i]];
}
}
return cur;
}
/**
* This is the "light", more performant version of `merge()`. It does not support a
* customizer function or being used as a lodash iterator.
*
* @method mergeLight
* @static
* @param {Object} target - the destination object
* @param {Object} sources - the source object
* @return {Object} - the merged object
*/
function mergeLight(target, ...sources) {
for (let source of sources) {
if (isTerminal(source)) {
target = source;
}
else {
if (isTerminal(target)) {
target = Array.isArray(source) ? Array(source.length) : {};
}
for (let key in source) {
let value = source[key];
if (value !== undefined) {
target[key] = mergeLight(target[key], value);
}
}
}
}
return target;
}
/**
* Merges n objects together.
*
* @method merge
* @static
* @param {Object} target - the destination object
* @param {Object} sources - the source object
* @param {Function} customizer - the function to customize merging properties
* If provided, customizer is invoked to produce the merged values of the destination and source
* properties. If customizer returns undefined, merging is handled by the method instead.
* @param {Mixed} customizer.objectValue - the value at `key` in the base object
* @param {Mixed} customizer.sourceValue - the value at `key` in the source object
* @param {Mixed} customizer.key - the key currently being merged
* @param {Mixed} customizer.object - the base object
* @param {Mixed} customizer.source - the source object
* @return {Object} - the merged object
*/
export function merge(target, ...sources) {
let lastSource = sources[sources.length - 1];
if (typeof lastSource === 'function' ||
(sources.length > 1 &&
Array.isArray(lastSource) &&
lastSource.indexOf(sources[0]) >= 0)) {
return mergeHeavy(target, ...sources);
}
else {
return mergeLight(target, ...sources);
}
}
/**
* This is the "heavy" version of `merge()`, which is significantly less performant than
* the light version, but supports customizers and being used as a lodash iterator.
*
* @method mergeHeavy
* @static
* @param {Object} object - the destination object
* @param {Object} sources - the source object
* @param {Function} customizer - the function to customize merging properties
* If provided, customizer is invoked to produce the merged values of the destination and source
* properties. If customizer returns undefined, merging is handled by the method instead.
* @param {Mixed} customizer.objectValue - the value at `key` in the base object
* @param {Mixed} customizer.sourceValue - the value at `key` in the source object
* @param {Mixed} customizer.key - the key currently being merged
* @param {Mixed} customizer.object - the base object
* @param {Mixed} customizer.source - the source object
* @return {Object} - the merged object
*/
export function mergeHeavy(object, ...sources) {
let customizer, lastSource = sources[sources.length - 1];
let source;
if (typeof lastSource === 'function') {
customizer = sources.pop();
lastSource = sources[sources.length - 1];
}
// check if merge is being used w/ map, reduce or similar
if (sources.length > 1 && _.isArray(lastSource) && lastSource.indexOf(sources[0]) >= 0) {
baseMergeHeavy(object, sources[0], customizer);
}
else {
for (source of sources) {
baseMergeHeavy(object, source, customizer);
}
}
return object;
}
function baseMergeHeavy(object, source, customizer) {
let key, srcValue, value, result, isCommon, hasValue, isNewValue;
if (!isScalar(object) && !isScalar(source)) {
for (key in source) {
srcValue = source[key];
if (isScalar(srcValue)) {
value = object[key];
result = customizer ? customizer(value, srcValue, key, object, source) : undefined;
// isCommon => use source value instead of customizer result
isCommon = result === undefined;
if (isCommon) {
result = srcValue;
}
hasValue = _.isArray(source) || result !== undefined;
isNewValue = !scalarEquals(result, value) && !(isNaN(result) && isNaN(value));
if (hasValue && (isCommon || isNewValue)) {
object[key] = result;
}
}
else {
baseMergeDeepHeavy(object, source, key, customizer);
}
}
}
return object;
}
function baseMergeDeepHeavy(object, source, key, customizer) {
let srcValue = source[key];
let value = object[key];
let result = customizer && customizer(value, srcValue, key, object, source);
let isCommon = result === undefined;
if (isCommon) {
result = isScalar(value) ? srcValue : value;
if (!_.isArray(srcValue)) {
if (_.isPlainObject(srcValue) || _.isArguments(srcValue)) {
if (_.isArguments(value)) {
result = _.toPlainObject(value);
}
else if (!_.isArray(value) && !_.isPlainObject(value)) {
result = {};
}
}
else {
isCommon = false;
}
}
}
// Recursively merge objects and arrays (susceptible to call stack limits).
if (isCommon) {
object[key] = baseMergeHeavy(result, srcValue, customizer);
}
else if (result !== value) {
object[key] = result;
}
}
/**
* Gets the duplicates in an array
*
* @method getDuplicates
* @static
* @param {Array} arr - the array to find duplicates in
* @return {Array} - contains the duplicates in arr
*/
const duplicatesSymbol = Symbol('duplicates');
export function getDuplicates(arr) {
return arr.reduce((memo, val) => {
switch (memo[val]) {
case true:
memo[duplicatesSymbol].push(val);
memo[val] = false;
case undefined:
memo[val] = true;
}
return memo;
}, { [duplicatesSymbol]: [] })[duplicatesSymbol];
}
/**
* Diffs n objects
*
* @method diffObjects
* @static
* @param {Object} ...objects - the objects to diff
* @return {Mixed} - If no scalars are passed, returns an object with arrays of values at every
* path from which all source objects are different. If scalars are passed, the return value is an
* array with non-numeric fields. Terminal arrays will contain objects only if they contain no
* overlapping keys.
* See README.md for usage examples.
*/
const getKeys = (objects) => {
return _.reduce(objects, (keys, obj) => {
if (!isScalar(obj)) {
let index, objKeys = Object.keys(obj);
for (index = objKeys.length - 1; index >= 0; index--) {
keys.unshift(objKeys[index]);
}
}
return keys;
}, []);
};
const getDuplicateKeys = (objects) => getDuplicates(getKeys(objects));
const getScalarOrNull = (val) => isScalar(val) ? val : null;
const getValueAtKeyOrNull = (key) => {
return (obj) => (obj && obj[key] !== undefined) ? obj[key] : null;
};
const isCollectionOrNull = (val) => val === null || !isScalar(val);
const hasNonNullScalars = (diff) => !_.every(diff, isCollectionOrNull);
export function diffObjects(...objects) {
const isHeterogeneousAtKey = (key) => {
return !_.every(objects, obj => obj && objects[0] && obj[key] === objects[0][key]);
};
let result = hasNonNullScalars(objects)
? _.map(objects, getScalarOrNull)
: {};
let index, diffKeys = _.filter(getKeys(objects), isHeterogeneousAtKey);
let diffValues;
for (index = diffKeys.length - 1; index > 0; index--) {
diffValues = _.map(objects, getValueAtKeyOrNull(diffKeys[index]));
if (getDuplicateKeys(diffValues).length === 0) {
result[diffKeys[index]] = diffValues;
}
else {
result[diffKeys[index]] = hasNonNullScalars(diffValues)
? Object.assign(diffValues, diffObjects(...diffValues))
: diffObjects(...diffValues);
}
}
return result;
}
/**
* Diffs two objects
*
* @method dottedDiff
* @static
* @param {Mixed} val1 - the first value to diff
* @param {Mixed} val2 - the second value to diff
* @return {String[]} - an array of dot-separated paths to the shallowest branches
* present in both objects from which there are no identical scalar values.
*/
export function dottedDiff(val1, val2) {
if (isScalar(val1) && isScalar(val2)) {
return val1 === val2 ? [] : [''];
}
else {
return Object.keys(addDottedDiffFieldsToSet({}, '', val1, val2));
}
}
function addDottedDiffFieldsToSet(fieldSet, fieldPath, value1, value2) {
let key, subfieldPath;
if (!isScalar(value1) && !isScalar(value2)) {
for (key in value1) {
subfieldPath = fieldPath ? (fieldPath + '.' + key) : key;
if (key in value2) {
addDottedDiffFieldsToSet(fieldSet, subfieldPath, value1[key], value2[key]);
}
else {
fieldSet[subfieldPath] = true;
}
}
for (key in value2) {
if (!(key in value1)) {
subfieldPath = fieldPath ? (fieldPath + '.' + key) : key;
fieldSet[subfieldPath] = true;
}
}
}
else if (!scalarEquals(value1, value2)) {
fieldSet[fieldPath] = true;
}
return fieldSet;
}
/**
* Construct a consistent hash of an object, array, or other Javascript entity.
*
* @method objectHash
* @static
* @param {Mixed} obj - The object to hash.
* @param {boolean} forceHash - Force hashing the object even if the hash key is short.
* @return {String} - The hash string. Long enough so collisions are extremely unlikely.
*/
export function objectHash(obj, forceHash = false) {
function hashKey(v) {
const t = typeof v;
if (Array.isArray(v)) {
let r = 'ar';
for (let el of v) {
let sk = hashKey(el);
r += sk.length + ' ' + sk;
}
return r;
}
else if (t === 'object' && v !== null && !(v instanceof Date)) {
let r = 'obj';
let keys = Object.keys(v);
keys.sort();
for (let key of keys) {
let sk = hashKey(v[key]);
r += key.length + ' ' + key + sk.length + ' ' + sk;
}
return r;
}
else {
return t + String(v);
}
}
const MAX_KEY_SIZE = 40;
let hk = hashKey(obj);
if (hk.length <= MAX_KEY_SIZE && !forceHash) {
return hk;
}
else {
const hash = createHash('md5');
hash.update(hk);
return hash.digest('hex');
}
}
/**
* Converts a date string, number of miliseconds or object with a date field into
* an instance of Date. It will return the same instance if a Date instance is passed
* in.
*
* @method sanitizeDate
* @param {String|Number|Object} val - the value to convert
* @return {Date} - returns the converted Date instance
*/
export function sanitizeDate(val) {
if (!val)
return null;
if (_.isDate(val))
return val;
if (_.isString(val))
return new Date(Date.parse(val));
if (_.isNumber(val))
return new Date(val);
// @ts-ignore
if (_.isObject(val) && val.date)
return sanitizeDate(val.date);
return null;
}
export function isPlainObject(val) {
let r = false;
if (typeof val === 'object' && val !== null) {
let proto = Object.getPrototypeOf(val);
r = proto === Object.prototype || proto === null;
}
return r;
}
export function isEmptyObject(val) {
if (typeof val === 'object' && val) {
for (let key in val) {
return false;
}
return true;
}
return false;
}
export function isEmptyArray(val) {
if (Array.isArray(val)) {
return !val.length;
}
if (val && typeof val.length === 'number') {
return !val.length;
}
return false;
}
export function isEmpty(val) {
if (Array.isArray(val)) {
return !val.length;
}
if (val) {
if (typeof val.length === 'number') {
return !val.length;
}
if (typeof val === 'object') {
for (let key in val) {
return false;
}
return true;
}
return false;
}
if (typeof val === 'number') {
return false;
}
return true;
}
//# sourceMappingURL=index.js.map