const extend = require('extend');
/**
* @module shared/objectUtils
*/
/**
* @callback ObjectUtilsShouldFlatFn
* @param {string} key The key for the property object that is being parsed.
* @param {Object} value The value of the property object that is being parsed.
* @returns {boolean} Whether or not the method should flat a sub object.
* @parent module:shared/objectUtils
*/
/**
* @typedef {Object.<string, string>} ObjectUtilsExtractPathsDictionary
* @parent module:shared/objectUtils
*/
/**
* @typedef {ObjectUtilsExtractPathsDictionary | string} ObjectUtilsExtractPath
* @parent module:shared/objectUtils
*/
/**
* A small collection of utility methods to work with objects.
*
* @parent module:shared/objectUtils
* @tutorial objectUtils
*/
class ObjectUtils {
/**
* Creates a deep copy of a given object.
*
* @param {Object} target The object to copy.
* @returns {Object}
*/
static copy(target) {
return this.merge(target);
}
/**
* A shorthand method for {@link ObjectUtils.formatKeys} that transforms the keys from
* `dash-case` to `lowerCamelCase`.
*
* @param {Object} target The object for format.
* @param {string[]} [include=[]] A list of keys or paths where the
* transformation will be made. If not specified,
* the method will use all the keys from the
* object.
* @param {string[]} [exclude=[]] A list of keys or paths where the
* transformation won't be made.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components for both `include` and `exclude`.
* @returns {Object}
*/
static dashToLowerCamelKeys(target, include = [], exclude = [], pathDelimiter = '.') {
return this.formatKeys(
target,
/([a-z])-([a-z])/g,
(fullMatch, firstLetter, secondLetter) => {
const newSecondLetter = secondLetter.toUpperCase();
return `${firstLetter}${newSecondLetter}`;
},
include,
exclude,
pathDelimiter,
);
}
/**
* A shorthand method for {@link ObjectUtils.formatKeys} that transforms the keys from
* `dash-case` to `snake_case`.
*
* @param {Object} target The object for format.
* @param {string[]} [include=[]] A list of keys or paths where the
* transformation will be made. If not specified,
* the method will use all the keys from the
* object.
* @param {string[]} [exclude=[]] A list of keys or paths where the
* transformation won't be made.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components for both `include` and `exclude`.
* @returns {Object}
*/
static dashToSnakeKeys(target, include = [], exclude = [], pathDelimiter = '.') {
return this.formatKeys(
target,
/([a-z])-([a-z])/g,
(fullMatch, firstLetter, secondLetter) => `${firstLetter}_${secondLetter}`,
include,
exclude,
pathDelimiter,
);
}
/**
* Deletes a property of an object using a path.
*
* @param {Object} target The object from where the property will
* be removed.
* @param {string} objPath The path to the property.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the
* path components.
* @param {boolean} [cleanEmptyProperties=true] If this flag is `true` and after
* removing the property the parent object
* is empty, it will remove it recursively
* until a non empty parent object is
* found.
* @param {boolean} [failWithError=false] Whether or not to throw an error when
* the path is invalid. If this is
* `false`, the method will silently fail.
* @returns {Object} A copy of the original object with the removed property/properties.
* @example
*
* const target = {
* propOne: {
* propOneSub: 'Charito!',
* },
* propTwo: '!!!',
* };
* console.log(ObjectUtils.delete(target, 'propOne.propOneSub'));
* // Will output { propTwo: '!!!' }
*
*/
static delete(
target,
objPath,
pathDelimiter = '.',
cleanEmptyProperties = true,
failWithError = false,
) {
const parts = objPath.split(pathDelimiter);
const last = parts.pop();
let result = this.copy(target);
if (parts.length) {
const parentPath = parts.join(pathDelimiter);
const parentObj = this.get(result, parentPath, pathDelimiter, failWithError);
delete parentObj[last];
if (cleanEmptyProperties && !Object.keys(parentObj).length) {
result = this.delete(
result,
parentPath,
pathDelimiter,
cleanEmptyProperties,
failWithError,
);
}
} else {
delete result[last];
}
return result;
}
/**
* Extracts a property or properties from an object in order to create a new one.
*
* @param {Object} target
* The object from where the property/properties will be extracted.
* @param {ObjectUtilsExtractPath | ObjectUtilsExtractPath[]} objPaths
* This can be a single path or a list of them. And for this method, the paths are not
* only strings but can also be an object with a single key, the would be the path to
* where to "do the extraction", and the value the path on the target object.
* @param {string} [pathDelimiter='.']
* The delimiter that will separate the path components.
* @param {boolean} [failWithError=false]
* Whether or not to throw an error when the path is invalid. If this is `false`, the
* method will silently fail an empty object.
* @returns {Object}
* @example
*
* const target = {
* name: {
* first: 'Rosario',
* },
* age: 3,
* address: {
* planet: 'earth',
* something: 'else',
* },
* };
* console.log(
* ObjectUtils.set(obj, [{ name: 'name.first' }, 'age', 'address.planet']),
* );
* // Will output { name: 'Rosario', age: 3, address: { planet: 'earth' } }
*
*/
static extract(target, objPaths, pathDelimiter = '.', failWithError = false) {
const copied = this.copy(target);
let result = {};
(Array.isArray(objPaths) ? objPaths : [objPaths])
.reduce((acc, objPath) => {
let destPath;
let originPath;
if (typeof objPath === 'object') {
[destPath] = Object.keys(objPath);
originPath = objPath[destPath];
} else {
destPath = objPath;
originPath = objPath;
}
return [
...acc,
{
origin: originPath,
customDest: destPath.includes(pathDelimiter),
dest: destPath,
},
];
}, [])
.some((pathInfo) => {
let breakLoop = false;
const value = this.get(copied, pathInfo.origin, pathDelimiter, failWithError);
if (typeof value !== 'undefined') {
if (pathInfo.customDest) {
result = this.set(result, pathInfo.dest, value, pathDelimiter, failWithError);
if (typeof result === 'undefined') {
breakLoop = true;
}
} else {
result[pathInfo.dest] = value;
}
}
return breakLoop;
});
return result;
}
/**
* Flatterns an object properties into a single level dictionary.
*
* @param {Object} target
* The object to transform.
* @param {string} [pathDelimiter='.']
* The delimiter that will separate the path components.
* @param {string} [prefix='']
* A custom prefix to be added before the name of the properties. This can be used on
* custom cases and it's also used when the method calls itself in order to flattern a
* sub object.
* @param {?ObjectUtilsShouldFlatFn} [shouldFlattern=null]
* A custom function that can be used in order to tell the method whether an Object or
* an Array property should be flattern or not. It will receive the key for the property
* and the Object/Array itself.
* @returns {Object}
* @example
*
* const target = {
* propOne: {
* propOneSub: 'Charito!',
* },
* propTwo: '!!!',
* };
* console.log(ObjectUtils.flat(target);
* // Will output { 'propOne.propOneSub': 'Charito!', propTwo: '!!!' }
*
*/
static flat(target, pathDelimiter = '.', prefix = '', shouldFlattern = null) {
let result = {};
const namePrefix = prefix ? `${prefix}${pathDelimiter}` : '';
Object.keys(target).forEach((key) => {
const name = `${namePrefix}${key}`;
const value = target[key];
const valueType = typeof value;
const isObject = valueType === 'object' && value !== null;
if (isObject && (!shouldFlattern || shouldFlattern(key, value))) {
result = this.merge(
result,
this.flat(value, pathDelimiter, name, shouldFlattern),
);
} else {
result[name] = isObject ? this.copy(value) : value;
}
});
return result;
}
/**
* Formats all the keys on an object using a way similar to `.replace(regexp, ...)` but
* that also works recursively and with _"object paths"_.
*
* @param {Object} target The object for format.
* @param {RegExp} searchExpression The regular expression the method will use
* "match" the keys.
* @param {Function} replaceWith The callback the method will call when
* formatting a replacement. Think of
* `searchExpression` and `replaceWith` as the
* parameters of a `.replace` call,
* where the object is the key.
* @param {string[]} [include=[]] A list of keys or paths where the
* transformation will be made. If not specified,
* the method will use all the keys from the
* object.
* @param {string[]} [exclude=[]] A list of keys or paths where the
* transformation won't be made.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components for both `include` and `exclude`.
* @returns {Object}
* @example
*
* const target = {
* prop_one: 'Charito!',
* };
* console.log(
* ObjectUtils.formatKeys(
* target,
* // Find all the keys with snake case.
* /([a-z])_([a-z])/g,
* // Using the same .replace style callback, replace it with lower camel case.
* (fullMatch, firstLetter, secondLetter) => {
* const newSecondLetter = secondLetter.toUpperCase();
* return `${firstLetter}${newSecondLetter}`;
* },
* ),
* );
* // Will output { propOne: 'Charito!}.
*
*/
static formatKeys(
target,
searchExpression,
replaceWith,
include = [],
exclude = [],
pathDelimiter = '.',
) {
// First of all, get all the keys from the target.
const keys = Object.keys(target);
/**
* Then, check which keys are parent to other objects.
* This is saved on a dictionary not only because it makes it easier to check if the
* method should make a recursive call for a key, but also because when parsing the
* `exclude`
* parameter, if one of items is a key (and not an specific path), the method won't
* make the recursive call.
*
* @ignore
*/
const hasChildrenByKey = {};
keys.forEach((key) => {
const value = target[key];
hasChildrenByKey[key] = !!(
value &&
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value)
);
});
/**
* Escape the path delimiter and create two regular expression: One that removes the path
* delimiter from the start of a path and one that removes it from the end.
* They are later used to normalize paths in order to avoid "incomplete paths" (paths that
* end or start with the delimiter).
*/
const escapedPathDelimiter = pathDelimiter.replace(
/[-[\]{}()*+?.,\\^$|#\s]/g,
'\\$&',
);
const cleanPathStartExpression = new RegExp(`^${escapedPathDelimiter}`, 'i');
const cleanPathEndExpression = new RegExp(`${escapedPathDelimiter}$`, 'i');
/**
* This dictionary will be used to save the `include` parameter that will be sent for specific
* keys on recursive calls.
* If `include` has a path like `myKey.mySubKey`, `myKey` is not transformed, but `mySubKey`
* is saved on this dictionary (`{ myKey: ['mySubKey']}`) and when the method applies the
* formatting to the object, if `myKey` has an object, it will make a recursive all and
* send `['mySubKey]` as its `include` parameter.
*/
const subIncludeByKey = {};
/**
* This will be an array containing the final list of `keys` that should be tranformed.
* To be clear, these keys will be from the top level, so, they won't be paths.
* Thd following blocks will parse `include` and `exclude` in order to extract the real keys,
* prepare the `include` and `exclude` for recursive calls, and save the actual keys
* from the object "at the current level of this call" (no, it's not thinking about the
* children :P).
*/
let keysToFormat;
// If the `include` parameter has paths/keys...
if (include.length) {
keysToFormat = include
.map((includePath) => {
// Normalize the path/key.
const useIncludePath = includePath
.replace(cleanPathStartExpression, '')
.replace(cleanPathEndExpression, '');
// Define the variable that will, eventually, have the real key.
let key;
// If the value is a path...
if (useIncludePath.includes(pathDelimiter)) {
// Split all its components.
const pathParts = useIncludePath.split(pathDelimiter);
// Get the first component, a.k.a. the real key.
const pathKey = pathParts.shift();
/**
* This is very important: Since the path was specified with sub components
* (like `myProp.mySubProp`), the method won't format the key, but the sub
* key(s)
* (`mySubProp`).
* The `key` is set to `false` so it will be later removed using `.filter`.
*
* @ignore
*/
key = false;
/**
* If there's no array for the key on the "`include` dictionary for recursive calls",
* create an empty one.
*/
if (!subIncludeByKey[pathKey]) {
subIncludeByKey[pathKey] = [];
}
// Save the rest of the path to be sent on the recursive call as `include`.
subIncludeByKey[pathKey].push(pathParts.join(pathDelimiter));
} else {
// If the value wasn't a path, assume it's a key, and set it to be returned.
key = useIncludePath;
}
return key;
})
// Remove any `false` keys.
.filter((key) => key);
} else {
// There's nothing on the `include` parameter, so use all the keys.
keysToFormat = keys;
}
/**
* Similar to `subIncludeByKey`, this dictionary will be used to save the `exclude` parameter
* that will be sent for specific keys on recursive calls.
* If `exclude` has a path like `myKey.mySubKey`, `myKey` will be transformed, but `mySubKey`
* is saved on this dictionary (`{ myKey: ['mySubKey']}`) and when the method applies the
* formatting to the object, if `myKey` has an object, it will make a recursive all and
* send `['mySubKey]` as its `exclude` parameter.
*/
const subExcludeByKey = {};
// If the `include` parameter has paths/keys...
if (exclude.length) {
/**
* Create a dictionary of keys that should be removed from `keysToFormat`.
* It's easier to have them on a list and use `.filter` than actually call `.splice` for
* every key that should be removed.
*/
const keysToRemove = [];
exclude.forEach((excludePath) => {
// Normalize the path/key.
const useExcludePath = excludePath
.replace(cleanPathStartExpression, '')
.replace(cleanPathEndExpression, '');
// If the value is a path...
if (useExcludePath.includes(pathDelimiter)) {
// Split all its components.
const pathParts = useExcludePath.split(pathDelimiter);
// Get the first component, a.k.a. the real key.
const pathKey = pathParts.shift();
/**
* If there's no array for the key on the "`exclude` dictionary for recursive calls",
* create an empty one.
*/
if (!subExcludeByKey[pathKey]) {
subExcludeByKey[pathKey] = [];
}
// Save the rest of the path to be sent on the recursive call as `exclude`.
subExcludeByKey[pathKey].push(pathParts.join(pathDelimiter));
} else {
/**
* If the value wasn't a path, assume it's a key, turn the flag on the "children
* dictionary" to `false`, to prevent recursive calls, and add the key to the
* list of keys that will be removed from `keysToFormat`.
* Basically: If it's a key, don't format it and don't make recursive calls for
* it.
*
* @ignore
*/
hasChildrenByKey[useExcludePath] = false;
keysToRemove.push(useExcludePath);
}
});
// Remove keys that should be excluded.
keysToFormat = keysToFormat.filter((key) => !keysToRemove.includes(key));
}
// "Finally", reduce all the keys from the object and create the new one...
return keys.reduce((newObj, key) => {
/**
* Define the new key and value for the object property. Depending on the validations,
* they may be replaced with formatted ones.
*/
let newKey;
let newValue;
/**
* Get the current value for the key, in case it's needed for a recursive call or just
* to send it back because it didn't need any change.
*/
const value = target[key];
// If the key should be formatted, apply the formatting; otherwise, keep the original.
if (keysToFormat.includes(key)) {
newKey = key.replace(searchExpression, replaceWith);
} else {
newKey = key;
}
/**
* If the paths/keys on `exclude` didn't modify the "children dictionary" for the key and
* the value is another object, make a recursive call; otherwise, just use the original
* value.
*/
if (hasChildrenByKey[key]) {
newValue = this.formatKeys(
value,
searchExpression,
replaceWith,
subIncludeByKey[key] || [],
subExcludeByKey[key] || [],
pathDelimiter,
);
} else {
newValue = value;
}
// "Done", return the new object with the "new key" and the "new value".
return { ...newObj, [newKey]: newValue };
}, {});
}
/**
* Returns the value of an object property using a path.
*
* @param {Object} target The object from where the property will be
* read.
* @param {string} objPath The path to the property.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components.
* @param {boolean} [failWithError=false] Whether or not to throw an error when the
* path is invalid. If this is `false`, the
* method will silently fail and return
* `undefined`.
* @returns {*}
* @throws {Error} If the path is invalid and `failWithError` is set to `true`.
* @example
*
* const obj = {
* propOne: {
* propOneSub: 'Charito!',
* },
* propTwo: '!!!',
* };
* console.log(ObjectUtils.get(obj, 'propOne.propOneSub'));
* // Will output 'Charito!'
*
*/
static get(target, objPath, pathDelimiter = '.', failWithError = false) {
const parts = objPath.split(pathDelimiter);
const first = parts.shift();
let currentElement = target[first];
if (typeof currentElement === 'undefined') {
if (failWithError) {
throw new Error(`There's nothing on '${objPath}'`);
}
} else if (parts.length) {
let currentPath = first;
parts.some((currentPart) => {
let breakLoop = false;
currentPath += `${pathDelimiter}${currentPart}`;
currentElement = currentElement[currentPart];
if (typeof currentElement === 'undefined') {
if (failWithError) {
throw new Error(`There's nothing on '${currentPath}'`);
} else {
breakLoop = true;
}
}
return breakLoop;
});
}
return currentElement;
}
/**
* A shorthand method for {@link ObjectUtils.formatKeys} that transforms the keys from
* `lowerCamelCase` to `dash-case`.
*
* @param {Object} target The object for format.
* @param {string[]} [include=[]] A list of keys or paths where the
* transformation will be made. If not specified,
* the method will use all the keys from the
* object.
* @param {string[]} [exclude=[]] A list of keys or paths where the
* transformation won't be made.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components for both `include` and `exclude`.
* @returns {Object}
*/
static lowerCamelToDashKeys(target, include = [], exclude = [], pathDelimiter = '.') {
return this.formatKeys(
target,
/([a-z])([A-Z])/g,
(fullMatch, firstLetter, secondLetter) => {
const newSecondLetter = secondLetter.toLowerCase();
return `${firstLetter}-${newSecondLetter}`;
},
include,
exclude,
pathDelimiter,
);
}
/**
* A shorthand method for {@link ObjectUtils.formatKeys} that transforms the keys from
* `lowerCamelCase` to `snake_case`.
*
* @param {Object} target The object for format.
* @param {string[]} [include=[]] A list of keys or paths where the
* transformation will be made. If not specified,
* the method will use all the keys from the
* object.
* @param {string[]} [exclude=[]] A list of keys or paths where the
* transformation won't be made.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components for both `include` and `exclude`.
* @returns {Object}
*/
static lowerCamelToSnakeKeys(target, include = [], exclude = [], pathDelimiter = '.') {
return this.formatKeys(
target,
/([a-z])([A-Z])/g,
(fullMatch, firstLetter, secondLetter) => {
const newSecondLetter = secondLetter.toLowerCase();
return `${firstLetter}_${newSecondLetter}`;
},
include,
exclude,
pathDelimiter,
);
}
/**
* This method makes a deep merge of a list of objects into a new one. The method also
* supports arrays.
*
* @param {...Object} targets The objects to merge.
* @returns {Object}
* @example
*
* const objA = { a: 'first' };
* const objB = { b: 'second' };
* console.log(ObjectUtils.merge(objA, objB));
* // Will output { a: 'first', b: 'second' }
*
* @example
*
* const arrA = [{ a: 'first' }];
* const arrB = [{ b: 'second' }];
* console.log(ObjectUtils.merge(objA, objB));
* // Will output [{ a: 'first', b: 'second' }]
*
*/
static merge(...targets) {
const [firstTarget] = targets;
const base = Array.isArray(firstTarget) ? [] : {};
return extend(true, base, ...targets);
}
/**
* Sets a property on an object using a path. If the path doesn't exist, it will be
* created.
*
* @param {Object} target The object where the property will be set.
* @param {string} objPath The path for the property.
* @param {*} value The value to set on the property.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components.
* @param {boolean} [failWithError=false] Whether or not to throw an error when the
* path is invalid. If this is `false`, the
* method will silently fail and return
* `undefined`.
* @returns {Object} A copy of the original object with the added property/properties.
* @throws {Error} If one of the path components is for a non-object property and
* `failWithError` is set to `true`.
* @example
*
* const target = {};
* console.log(ObjectUtils.set(target, 'some.prop.path', 'some-value'));
* // Will output { some: { prop: { path: 'some-value' } } }
*
*/
static set(target, objPath, value, pathDelimiter = '.', failWithError = false) {
let result = this.copy(target);
if (objPath.includes(pathDelimiter)) {
const parts = objPath.split(pathDelimiter);
const last = parts.pop();
let currentElement = result;
let currentPath = '';
parts.forEach((part) => {
currentPath += `${pathDelimiter}${part}`;
const element = currentElement[part];
const elementType = typeof element;
if (elementType === 'undefined') {
currentElement[part] = {};
currentElement = currentElement[part];
} else if (elementType === 'object') {
currentElement = currentElement[part];
} else {
const errorPath = currentPath.substr(pathDelimiter.length);
if (failWithError) {
throw new Error(
`There's already an element of type '${elementType}' on '${errorPath}'`,
);
} else {
result = undefined;
}
}
});
if (result) {
currentElement[last] = value;
}
} else {
result[objPath] = value;
}
return result;
}
/**
* A shorthand method for {@link ObjectUtils.formatKeys} that transforms the keys from
* `snake_case` to `dash-case`.
*
* @param {Object} target The object for format.
* @param {string[]} [include=[]] A list of keys or paths where the
* transformation will be made. If not specified,
* the method will use all the keys from the
* object.
* @param {string[]} [exclude=[]] A list of keys or paths where the
* transformation won't be made.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components for both `include` and `exclude`.
* @returns {Object}
*/
static snakeToDashKeys(target, include = [], exclude = [], pathDelimiter = '.') {
return this.formatKeys(
target,
/([a-z])_([a-z])/g,
(fullMatch, firstLetter, secondLetter) => `${firstLetter}-${secondLetter}`,
include,
exclude,
pathDelimiter,
);
}
/**
* A shorthand method for {@link ObjectUtils.formatKeys} that transforms the keys from
* `snake_case` to `lowerCamelCase`.
*
* @param {Object} target The object for format.
* @param {string[]} [include=[]] A list of keys or paths where the
* transformation will be made. If not specified,
* the method will use all the keys from the
* object.
* @param {string[]} [exclude=[]] A list of keys or paths where the
* transformation won't be made.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components for both `include` and `exclude`.
* @returns {Object}
*/
static snakeToLowerCamelKeys(target, include = [], exclude = [], pathDelimiter = '.') {
return this.formatKeys(
target,
/([a-z])_([a-z])/g,
(fullMatch, firstLetter, secondLetter) => {
const newSecondLetter = secondLetter.toUpperCase();
return `${firstLetter}${newSecondLetter}`;
},
include,
exclude,
pathDelimiter,
);
}
/**
* This method does the exact opposite from `flat`: It takes an already flattern object
* and restores it structure.
*
* @param {Object} target The object to transform.
* @param {string} [pathDelimiter='.'] The delimiter that will separate the path
* components.
* @returns {Object}
* @example
*
* const target = {
* 'propOne.propOneSub': 'Charito!',
* propTwo: '!!!',
* };
* console.log(ObjectUtils.unflat(target));
* // Will output { propOne: { propOneSub: 'Charito!' }, 'propTwo': '!!!' }
*
*/
static unflat(target, pathDelimiter = '.') {
return Object.keys(target).reduce(
(current, key) => this.set(current, key, target[key], pathDelimiter),
{},
);
}
/**
* @throws {Error} If instantiated. This class is meant to be have only static
* methods.
*/
constructor() {
throw new Error('ObjectUtils is a static class');
}
}
module.exports = ObjectUtils;