@irrelon/path
Version:
A powerful JSON path processor. Allows you to drill into and manipulate JSON objects with a simple dot-delimited path format e.g. "obj.name".
1,650 lines (1,390 loc) • 65.2 kB
text/typescript
export type ObjectType = { [key: string]: any };
export type ArrayType = Map<string, any[]>;
export type ValueType =
"undefined"
| "object"
| "boolean"
| "number"
| "string"
| "function"
| "symbol"
| "bigint"
| "null"
| "array";
export type DifferenceType = "value" | "type" | "both";
export interface PathData {
indices?: number[][];
directPaths?: string[];
}
export type QueryMatchFunction = (val: any) => boolean;
export interface OptionsType {
transformRead?: (...rest: any) => any;
transformKey?: (...rest: any) => any;
transformWrite?: (...rest: any) => any;
leavesOnly?: boolean;
}
export interface GetOptionsType extends OptionsType {
wildcardExpansion?: boolean; // Gets all results from an array when encountering a $ e.g. arr.$.value would return an array of `value` in all arr items
arrayTraversal?: boolean; // Will traverse arrays without the need for a wildcard e.g. arr.value would look inside `arr: [{value: true}]`
arrayExpansion?: boolean; // Same as wildcardExpansion but does not require a wildcard in the path
expandedResult?: any[]; // Used to store data while processing
pathData?: PathData; // Used to store data while processing
pathRoot?: string;
}
export interface SetOptionsType extends GetOptionsType {
immutable?: boolean;
strict?: boolean;
ignore?: RegExp;
}
export interface FindOptionsType extends OptionsType {
maxDepth?: number;
currentDepth?: number;
includeRoot?: boolean;
}
export interface DiffValue {
val1: unknown;
val2: unknown;
type1: ValueType;
type2: ValueType;
difference: DifferenceType;
}
export interface DiffOptionsType {
basePath?: string | string[];
strict?: boolean;
maxDepth?: number;
exclusive?: boolean;
}
/**
* Defines the options for merging objects.
* @interface
*/
export interface MergeOptionsType {
immutable?: boolean;
/**
* A flag indicating whether undefined values should override
* existing target values or not. If true, undefined values
* will not be set in the target object from the source object.
*
* @type {boolean | undefined}
*/
ignoreUndefined?: boolean;
}
/**
* @typedef {object} FindOptionsType
* @property {number} [maxDepth=Infinity] The maximum depth to scan inside
* the source object for matching data.
* @property {number} [currentDepth=0] The current depth of the
* operation scan.
* @property {boolean} [includeRoot=true] If true, will include the
* root source object if it matches the query.
*/
/**
* Scans an object for all keys that are either objects or arrays
* and returns an array of those keys only.
* @param {ObjectType} obj The object to scan.
* @returns {string[]} An array of string keys.
* @private
*/
const _iterableKeys = (obj: object | Array<any>): string[] => {
return Object.entries(obj).reduce((arr: string[], [key, val]) => {
const valType = type(val);
if (valType === "object" || valType === "array") {
arr.push(key);
}
return arr;
}, []);
};
/**
* Creates a new instance of "item" that is dereferenced. Useful
* when you want to return a new version of "item" with the same
* data for immutable data structures.
* @param {ObjectType} item The item to mimic.
* @param {string} key The key to set data in.
* @param {*} val The data to set in the key.
* @returns {*} A new dereferenced version of "item" with the "key"
* containing the "val" data.
* @private
*/
const _newInstance = (item: ObjectType, key?: string, val?: any): any => {
const objType = type(item);
let newObj;
if (objType === "object") {
newObj = {
...item
};
}
if (objType === "array") {
// @ts-ignore
newObj = [...item];
}
if (key !== undefined) {
// @ts-ignore
newObj[key] = val;
}
return newObj;
};
/**
* Determines if the given path points to a root leaf node (has no delimiter)
* or contains a dot delimiter so will drill down before reaching a leaf node.
* If it has a delimiter, it is called a "composite" path.
* @param {string} path The path to evaluate.
* @returns {boolean} True if delimiter found, false if not.
*/
export const isCompositePath = (path: string): boolean => {
const regExp = /\./g;
let result;
while (result = regExp.exec(path)) {
// Check if the previous character was an escape
// and if so, ignore this delimiter
if (result.index === 0 || path.substr(result.index - 1, 1) !== "\\") {
// This is not an escaped path, so it IS a composite path
return true;
}
}
return false;
};
/**
* Provides the opposite of `isCompositePath()`. If a delimiter is found, this
* function returns false.
* @param {string} path The path to evaluate.
* @returns {boolean} False if delimiter found, true if not.
*/
export const isNonCompositePath = (path: string): boolean => {
return !isCompositePath(path);
};
/**
* Returns the given path after removing the last
* leaf from the path. E.g. "foo.bar.thing" becomes
* "foo.bar".
* @param {string} path The path to operate on.
* @param {number} [levels=1] The number of levels to
* move up.
* @returns {string} The new path string.
*/
export const up = (path: string, levels: number = 1): string => {
const parts = split(path);
for (let i = 0; i < levels; i++) {
parts.pop();
}
return parts.join(".");
};
/**
* Returns the given path after removing the first
* leaf from the path. E.g. "foo.bar.thing" becomes
* "bar.thing".
* @param {string} path The path to operate on.
* @param {number} [levels] The number of levels to
* move down.
* @returns {string} The new path string.
*/
export const down = (path: string, levels: number = 1): string => {
const parts = split(path);
for (let i = 0; i < levels; i++) {
parts.shift();
}
return parts.join(".");
};
/**
* Returns the last leaf from the path. E.g.
* "foo.bar.thing" returns "thing".
* @param {string} path The path to operate on.
* @param {number} [levels] The number of levels to
* pop.
* @returns {string} The new path string.
*/
export const pop = (path: string, levels: number = 1): string => {
const parts = split(path);
let part;
for (let i = 0; i < levels; i++) {
part = parts.pop();
}
return part || "";
};
/**
* Adds a leaf to the end of the path. E.g.
* pushing "goo" to path "foo.bar.thing" returns
* "foo.bar.thing.goo".
* @param {string} path The path to operate on.
* @param {string} val The string value to push
* to the end of the path.
* @returns {string} The new path string.
*/
export const push = (path: string, val: string = ""): string => {
return `${path}.${val}`;
};
/**
* Returns the first leaf from the path. E.g.
* "foo.bar.thing" returns "foo".
* @param {string} path The path to operate on.
* @param {number} [levels=1] The number of levels to
* shift.
* @returns {string} The new path string.
*/
export const shift = (path: string, levels: number = 1): string => {
const parts = split(path);
let part;
for (let i = 0; i < levels; i++) {
part = parts.shift();
}
return part || "";
};
/**
* A function that just returns the first argument.
* @param {*} val The argument to return.
* @param {*} [currentObj] The current object hierarchy.
* @returns {*} The passed argument.
*/
export const returnWhatWasGiven = (val: any, currentObj: any): any => val;
/**
* Converts any key matching the wildcard to a zero.
* @param {string} key The key to test.
* @param {*} [currentObj] The current object hierarchy.
* @returns {string} The key.
*/
export const wildcardToZero = (key: string, currentObj: any): string => {
return key === "$" ? "0" : key;
};
/**
* If a key is a number, will return a wildcard, otherwise
* will return the originally passed key.
* @param {string} key The key to test.
* @returns {string} The original key or a wildcard.
*/
export const numberToWildcard = (key: string): string => {
// Check if the key is a number
if (String(parseInt(key, 10)) === key) {
// The key is a number, convert to a wildcard
return "$";
}
return key;
};
/**
* Removes leading period (.) from string and returns new string.
* @param {string} str The string to clean.
* @returns {*} The cleaned string.
*/
export const clean = (str: string): any => {
if (!str) {
return str;
}
if (str.substr(0, 1) === ".") {
str = str.substr(1, str.length - 1);
}
return str;
};
/**
* Splits a path by period character, taking into account
* escaped period characters.
* @param {string} path The path to split into an array.
* @return {Array<string>} The component parts of the path, split
* by period character.
*/
export const split = (path: string): Array<string> => {
// Convert all \. (escaped periods) to another character
// temporarily
const escapedPath = path.replace(/\\\./g, "[--]");
const splitPath = escapedPath.split(".");
// Loop the split path array and convert any escaped period
// placeholders back to their real period characters
for (let i = 0; i < splitPath.length; i++) {
splitPath[i] = splitPath[i].replace(/\[--]/g, "\\.");
}
return splitPath;
};
/**
* Escapes any periods in the passed string so they will
* not be identified as part of a path. Useful if you have
* a path like "domains.www.google.com.data" where the
* "www.google.com" should not be considered part of the
* traversal as it is actually in an object like:
* {
* "domains": {
* "www.google.com": {
* "data": "foo"
* }
* }
* }
* @param {string} str The string to escape periods in.
* @return {string} The escaped string.
*/
export const escape = (str: string): string => {
return str.replace(/\./g, "\\.");
};
/**
* Converts a string previously escaped with the `escape()`
* function back to its original value.
* @param {string} str The string to unescape.
* @returns {string} The unescaped string.
*/
export const unEscape = (str: string): string => {
return str.replace(/\\./g, ".");
};
/**
* Gets a single value from the passed object and given path.
* @param {ObjectType} obj The object to operate on.
* @param {string} path The path to retrieve data from.
* @param {*=} defaultVal Optional default to return if the
* value retrieved from the given object and path equals undefined.
* @param {OptionsType} [options] Optional options object.
* @returns {*} The value retrieved from the passed object at
* the passed path.
*/
export const get = (obj: ObjectType | undefined | null, path: string | any[], defaultVal: any | undefined = undefined, options: GetOptionsType = {}): any => {
let internalPath = path,
objPart;
if (path instanceof Array) {
return path.map((individualPath) => {
get(obj, individualPath, defaultVal, options);
});
}
options.transformRead = options.transformRead || returnWhatWasGiven;
options.transformKey = options.transformKey || returnWhatWasGiven;
options.transformWrite = options.transformWrite || returnWhatWasGiven;
// No object data, return default data
if (obj === undefined || obj === null) {
return defaultVal;
}
// No path string, return the base obj
if (!internalPath) {
return obj;
}
// @ts-ignore
internalPath = clean(internalPath);
// Path is not a string, throw error
if (typeof internalPath !== "string") {
throw new Error("Path argument must be a string");
}
// Path has no dot-notation, return key/value
if (isNonCompositePath(internalPath)) {
// @ts-ignore
return obj[internalPath] !== undefined ? obj[internalPath] : defaultVal;
}
if (typeof obj !== "object") {
return defaultVal !== undefined ? defaultVal : undefined;
}
const pathParts = split(internalPath);
objPart = obj;
for (let i = 0; i < pathParts.length; i++) {
const pathPart = pathParts[i];
const transformedKey: string = options.transformKey(unEscape(pathPart), objPart);
options.pathRoot = join(options.pathRoot || "", pathPart);
// @ts-ignore
objPart = objPart[transformedKey];
const isPartAnArray = objPart instanceof Array;
if (isPartAnArray && options.wildcardExpansion === true) {
const nextKey = options.transformKey(unEscape(pathParts[i + 1] || ""), objPart);
if (nextKey === "$") {
// Define an array to store our results in down the tree
options.expandedResult = options.expandedResult || [];
// The key is a wildcard and wildcardExpansion is enabled
objPart.forEach((arrItem: ObjectType) => {
const innerKey = pathParts.slice(i + 2).join(".");
if (innerKey === "") {
// @ts-ignore
options.expandedResult.push(arrItem);
} else {
const innerResult = get(arrItem, innerKey, defaultVal, options);
if (innerKey.indexOf(".$") === -1) {
// @ts-ignore
options.expandedResult.push(innerResult);
}
}
});
return options.expandedResult.length !== 0 ? options.expandedResult : defaultVal;
}
}
if (isPartAnArray && options.arrayTraversal === true) {
// The data is an array and we have arrayTraversal enabled
// Check for auto-expansion
if (options.arrayExpansion === true) {
return getMany(objPart, pathParts.slice(i + 1).join("."), defaultVal, options);
}
// Loop the array items and return the first non-undefined
// value from any array item leaf node that matches the path
for (let objPartIndex = 0; objPartIndex < objPart.length; objPartIndex++) {
const arrItem = objPart[objPartIndex];
const innerResult = get(arrItem, pathParts.slice(i + 1).join("."), defaultVal, options);
if (innerResult !== undefined) return innerResult;
}
return defaultVal;
} else if ((!objPart || typeof objPart !== "object") && i !== pathParts.length - 1) {
// The path terminated in the object before we reached
// the end node we wanted so make sure we return undefined
return defaultVal;
}
}
return objPart !== undefined ? objPart : defaultVal;
};
/**
* Gets multiple values from the passed arr and given path.
* @param {ObjectType} data The array or object to operate on.
* @param {string} path The path to retrieve data from.
* @param {*=} defaultVal Optional default to return if the
* value retrieved from the given object and path equals undefined.
* @param {OptionsType} [options] Optional options object.
* @returns {Array}
*/
export const getMany = (data: ObjectType, path: string, defaultVal: any | undefined = undefined, options: GetOptionsType | undefined = {}): any[] => {
const isDataAnArray = data instanceof Array;
const pathRoot = options.pathRoot || "";
if (!isDataAnArray) {
const innerResult = get(data, path, defaultVal, options);
const isInnerResultAnArray = innerResult instanceof Array;
options.pathData?.directPaths?.push(join(options.pathRoot || "", path));
if (isInnerResultAnArray) return innerResult;
if (innerResult === undefined && defaultVal === undefined) return [];
if (innerResult === undefined && defaultVal !== undefined) return [defaultVal];
return [innerResult];
}
const parts = split(path);
const firstPart = parts[0];
const pathRemainder = parts.slice(1).join(".");
const resultArr = data.reduce((innerResult, arrItem, arrIndex) => {
const isArrItemAnArray = arrItem[firstPart] instanceof Array;
if (isArrItemAnArray) {
options.pathRoot = join(pathRoot || "", String(arrIndex), firstPart);
const recurseResult = getMany(arrItem[firstPart], pathRemainder, defaultVal, options);
innerResult.push(...recurseResult);
return innerResult;
}
const val = get(arrItem, path, defaultVal, options);
if (val !== undefined) {
options.pathData?.directPaths?.push(join(options.pathRoot || "", String(arrIndex), path));
innerResult.push(val);
}
return innerResult;
}, []);
if (resultArr.length === 0 && defaultVal !== undefined) return [defaultVal];
return resultArr;
};
/**
* Sets a single value on the passed object and given path. This
* will directly modify the "obj" object. If you need immutable
* updates, use setImmutable() instead.
* @param {ObjectType} obj The object to operate on.
* @param {string} path The path to set data on.
* @param {*} val The value to assign to the obj at the path.
* @param {SetOptionsType} [options] The options object.
* @returns {*} Nothing.
*/
export const set = (obj: ObjectType, path: string, val: any, options: SetOptionsType = {}): any => {
let internalPath = path,
objPart;
options.transformRead = options.transformRead || returnWhatWasGiven;
options.transformKey = options.transformKey || returnWhatWasGiven;
options.transformWrite = options.transformWrite || returnWhatWasGiven;
// No object data
if (obj === undefined || obj === null) {
return;
}
// No path string
if (!internalPath) {
return;
}
internalPath = clean(internalPath);
// Path is not a string, throw error
if (typeof internalPath !== "string") {
throw new Error("Path argument must be a string");
}
if (typeof obj !== "object") {
return;
}
// Path has no dot-notation, set key/value
if (isNonCompositePath(internalPath)) {
const unescapedPath = unEscape(internalPath);
// Do not allow prototype pollution
if (unescapedPath === "__proto__") {
return obj;
}
obj = decouple(obj, options);
// @ts-ignore
obj[options.transformKey(unescapedPath)] = val;
return obj;
}
const newObj = decouple(obj, options);
const pathParts = split(internalPath);
const pathPart = pathParts.shift();
// @ts-ignore
const transformedPathPart = options.transformKey(pathPart);
// Do not allow prototype pollution
if (transformedPathPart === "__proto__") {
return obj;
}
let childPart = newObj[transformedPathPart];
if (typeof childPart !== "object" || childPart === null) {
// Create an object or array on the path
if (String(parseInt(transformedPathPart, 10)) === transformedPathPart || (pathParts.length > 0 && String(parseInt(pathParts[0], 10)) === pathParts[0])) {
// This is an array index
newObj[transformedPathPart] = [];
} else {
newObj[transformedPathPart] = {};
}
objPart = newObj[transformedPathPart];
} else {
objPart = childPart;
}
return set(newObj, transformedPathPart, set(objPart, pathParts.join("."), val, options), options);
};
/**
* Deletes a key from an object by the given path.
* @param {ObjectType} obj The object to operate on.
* @param {string} path The path to delete.
* @param {SetOptionsType} [options] The options object.
* @param {Object=} tracking Do not use.
*/
export const unSet = (obj: ObjectType, path: string, options: SetOptionsType = {}, tracking: {
returnOriginal?: boolean
} = {}) => {
let internalPath = path;
options.transformRead = options.transformRead || returnWhatWasGiven;
options.transformKey = options.transformKey || returnWhatWasGiven;
options.transformWrite = options.transformWrite || returnWhatWasGiven;
// No object data
if (obj === undefined || obj === null) {
return;
}
// No path string
if (!internalPath) {
return;
}
internalPath = clean(internalPath);
// Path is not a string, throw error
if (typeof internalPath !== "string") {
throw new Error("Path argument must be a string");
}
if (typeof obj !== "object") {
return;
}
const newObj = decouple(obj, options);
// Path has no dot-notation, set key/value
if (isNonCompositePath(internalPath)) {
const unescapedPath = unEscape(internalPath);
// Do not allow prototype pollution
if (unescapedPath === "__proto__") return obj;
if (newObj.hasOwnProperty(unescapedPath)) {
// @ts-ignore
delete newObj[options.transformKey(unescapedPath)];
return newObj;
}
tracking.returnOriginal = true;
return obj;
}
const pathParts = split(internalPath);
const pathPart = pathParts.shift();
// @ts-ignore
const transformedPathPart = options.transformKey(unEscape(pathPart));
// Do not allow prototype pollution
if (transformedPathPart === "__proto__") return obj;
let childPart = newObj[transformedPathPart];
if (!childPart) {
// No child part available, nothing to unset!
tracking.returnOriginal = true;
return obj;
}
newObj[transformedPathPart] = unSet(childPart, pathParts.join("."), options, tracking);
if (tracking.returnOriginal) {
return obj;
}
return newObj;
};
/**
* Takes an update object or array and iterates the keys of it, then
* sets data on the target object or array at the specified path with
* the corresponding value from the path key, effectively doing
* multiple set() operations in a single call. This will directly
* modify the "obj" object. If you need immutable updates, use
* updateImmutable() instead.
* @param {ObjectType} obj The object to operate on.
* @param {string} [basePath=""] The path to the object to operate on relative
* to the `obj`. If `obj` is the object to be directly operated on, leave
* `basePath` as an empty string.
* @param {ObjectType} updateData The update data to apply with
* keys as string paths.
* @param {SetOptionsType} options The options object.
* @returns {*} The object with the modified data.
*/
export const update = (obj: ObjectType, basePath: string = "", updateData: ObjectType, options: SetOptionsType = {}): any => {
let newObj = obj;
for (let path in updateData) {
if (updateData.hasOwnProperty(path)) {
// @ts-ignore
const data = updateData[path];
newObj = set(newObj, join(basePath, path), data, options);
}
}
return newObj;
};
/**
* Same as update() but will not change or modify the existing `obj`.
* References to objects that were not modified remain the same.
* @param {ObjectType} obj The object to operate on.
* @param {string} [basePath=""] The path to the object to operate on relative
* to the `obj`. If `obj` is the object to be directly operated on, leave
* `basePath` as an empty string.
* @param {ObjectType} updateData The update data to apply with
* keys as string paths.
* @param {SetOptionsType} [options] The options object.
* @returns {*} The new object with the modified data.
*/
export const updateImmutable = (obj: ObjectType, basePath: string = "", updateData: ObjectType, options: SetOptionsType = {}): any => {
return update(obj, basePath, updateData, {...options, immutable: true});
};
/**
* If `options.immutable` === true then return a new de-referenced
* instance of the passed object/array. If immutable is false
* then simply return the same `obj` that was passed.
* @param {*} obj The object or array to decouple.
* @param {OptionsType} [options] The options object.
* @param {boolean} options.immutable
* @returns {*} The new decoupled instance (if immutable is true)
* or the original `obj` if immutable is false.
*/
export const decouple = (obj: any, options: SetOptionsType = {}): any => {
if (!options.immutable) {
return obj;
}
return _newInstance(obj);
};
/**
* Push a value to an array on an object for the specified path.
* @param {ObjectType} obj The object to update.
* @param {string} path The path to the array to push to.
* @param {*} val The value to push to the array at the object path.
* @param {OptionsType} [options] An options object.
* @returns {ObjectType} The original object passed in "obj" but with
* the array at the path specified having the newly pushed value.
*/
export const pushVal = (obj: ObjectType, path: string, val: any, options: SetOptionsType = {}): ObjectType => {
if (obj === undefined || obj === null || path === undefined) {
return obj;
}
// Clean the path
path = clean(path);
const pathParts = split(path);
const part = pathParts.shift();
if (part === "__proto__") return obj;
if (pathParts.length) {
// Generate the path part in the object if it does not already exist
// @ts-ignore
obj[part] = decouple(obj[part], options) || {};
// Recurse
// @ts-ignore
pushVal(obj[part], pathParts.join("."), val, options);
} else if (part) {
// We have found the target array, push the value
// @ts-ignore
obj[part] = decouple(obj[part], options) || [];
// @ts-ignore
if (!(obj[part] instanceof Array)) {
throw ("Cannot push to a path whose leaf node is not an array!");
}
// @ts-ignore
obj[part].push(val);
} else {
// We have found the target array, push the value
obj = decouple(obj, options) || [];
if (!(obj instanceof Array)) {
throw ("Cannot push to a path whose leaf node is not an array!");
}
obj.push(val);
}
return decouple(obj, options);
};
/**
* Pull a value to from an array at the specified path. Removes the first
* matching value, not every matching value.
* @param {ObjectType} obj The object to update.
* @param {string} path The path to the array to pull from.
* @param {*} val The value to pull from the array.
* @param {OptionsType} [options] An options object.
* @returns {ObjectType} The original object passed in "obj" but with
* the array at the path specified having removed the newly pulled value.
*/
export const pullVal = (obj: ObjectType, path: string, val: any, options: SetOptionsType = {strict: true}): ObjectType => {
if (obj === undefined || obj === null || path === undefined) {
return obj;
}
// Clean the path
path = clean(path);
const pathParts = split(path);
const part = pathParts.shift();
if (part === "__proto__") return obj;
obj = decouple(obj, options);
if (pathParts.length && part !== undefined) {
// Recurse - we don't need to assign obj[part] the result of this call because
// we are modifying by reference since we haven't reached the furthest path
// part (leaf) node yet
obj[part] = pullVal(obj[part], pathParts.join("."), val, options);
} else if (part) {
// Recurse - this is the leaf node so assign the response to obj[part] in
// case it is set to an immutable response
obj[part] = pullVal(obj[part], "", val, options);
} else {
// The target array is the root object, pull the value
if (!(obj instanceof Array)) {
throw ("Cannot pull from a path whose leaf node is not an array!");
}
let index: number;
// Find the index of the passed value
if (options.strict === true) {
index = obj.indexOf(val);
} else {
// Do a non-strict check
index = obj.findIndex((item) => {
return match(item, val);
});
}
if (index > -1) {
// Remove the item from the array
obj.splice(index, 1);
}
}
return obj;
};
/**
* Inserts or deletes from/into the array at the specified path.
* @param {ObjectType} obj The object to update.
* @param {string} path The path to the array to operate on.
* @param {number} start The index to operate from.
* @param {number} deleteCount The number of items to delete.
* @param {any[]} itemsToAdd The items to add to the array or an empty array
* if no items are to be added.
* @param {OptionsType} [options] An options object.
* @returns {ObjectType} The original object passed in "obj" but with
* the array at the path specified having inserted or removed based on splice.
*/
export const splicePath = (obj: ObjectType, path: string, start: number, deleteCount: number, itemsToAdd: any[] = [], options: SetOptionsType = {strict: true}): ObjectType => {
if (obj === undefined || obj === null || path === undefined) {
return obj;
}
// Clean the path
path = clean(path);
const pathParts = split(path);
const part = pathParts.shift();
if (part === "__proto__") return obj;
obj = decouple(obj, options);
if (pathParts.length && part !== undefined) {
// Recurse - we don't need to assign obj[part] the result of this call because
// we are modifying by reference since we haven't reached the furthest path
// part (leaf) node yet
obj[part] = splicePath(obj[part], pathParts.join("."), start, deleteCount, itemsToAdd, options);
} else if (part) {
if (!(obj[part] instanceof Array)) {
throw ("Cannot splice from a path whose leaf node is not an array!");
}
// We've reached our destination leaf node
// Remove the item from the array
obj[part] = decouple(obj[part], options);
obj[part].splice(start, deleteCount, ...itemsToAdd);
} else {
if (!(obj instanceof Array)) {
throw ("Cannot splice from a path whose leaf node is not an array!");
}
// We've reached our destination leaf node
// Remove the item from the array
obj.splice(start, deleteCount, ...itemsToAdd);
}
return obj;
};
/**
* Given a path and an object, determines the outermost leaf node
* that can be reached where the leaf value is not undefined.
* @param {ObjectType} obj The object to operate on.
* @param {string} path The path to retrieve data from.
* @param {OptionsType} [options] Optional options object.
* @returns {string} The path to the furthest non-undefined value.
*/
export const furthest = (obj: ObjectType, path: string, options: OptionsType = {}): string => {
let internalPath = path,
objPart;
options.transformRead = options.transformRead || returnWhatWasGiven;
options.transformKey = options.transformKey || wildcardToZero;
options.transformWrite = options.transformWrite || returnWhatWasGiven;
const finalPath: string[] = [];
// No path string, return the base obj
if (!internalPath) {
return finalPath.join(".");
}
internalPath = clean(internalPath);
// Path is not a string, throw error
if (typeof internalPath !== "string") {
throw new Error("Path argument must be a string");
}
if (typeof obj !== "object" || obj === null) {
return finalPath.join(".");
}
// Path has no dot-notation, return key/value
if (isNonCompositePath(internalPath)) {
if (obj[internalPath] !== undefined) {
return internalPath;
}
return finalPath.join(".");
}
const pathParts = split(internalPath);
objPart = obj;
for (let i = 0; i < pathParts.length; i++) {
const pathPart = pathParts[i];
objPart = objPart[options.transformKey(unEscape(pathPart))];
if (objPart === undefined) {
break;
}
finalPath.push(pathPart);
}
return finalPath.join(".");
};
/**
* Traverses the object by the given path and returns an object where
* each key is a path pointing to a leaf node and contains the value
* from the leaf node from the overall object in the obj argument,
* essentially providing all available paths in an object and all the
* values for each path.
* @param {ObjectType} obj The object to operate on.
* @param {string} path The path to retrieve data from.
* @param {OptionsType} [options] Optional options object.
* @returns {ObjectType} The result of the traversal.
*/
export const values = (obj: ObjectType, path: string, options: OptionsType = {}): ObjectType => {
const internalPath = clean(path);
const pathParts = split(internalPath);
const currentPath = [];
const valueData = {};
options.transformRead = options.transformRead || returnWhatWasGiven;
options.transformKey = options.transformKey || returnWhatWasGiven;
options.transformWrite = options.transformWrite || returnWhatWasGiven;
for (let i = 0; i < pathParts.length; i++) {
const pathPart: string = options.transformKey(pathParts[i]);
currentPath.push(pathPart);
const tmpPath = currentPath.join(".");
// @ts-ignore
valueData[tmpPath] = get(obj, tmpPath);
}
// @ts-ignore
return valueData;
};
/**
* Takes an object and finds all paths, then returns the paths as an
* array of strings.
* @param obj The object to scan.
* @param finalArr An object used to collect the path keys.
* (Do not pass this in directly - use undefined).
* @param parentPath The path of the parent object. (Do not
* pass this in directly - use undefined).
* @param [options] An options object.
* @param [objCache] Internal, do not use.
* @returns An array containing path strings.
*/
export const flatten = (obj: ObjectType, finalArr: any[] | undefined = [], parentPath: string = "", options: SetOptionsType = {}, objCache = []): string[] => {
options.transformRead = options.transformRead || returnWhatWasGiven;
options.transformKey = options.transformKey || returnWhatWasGiven;
options.transformWrite = options.transformWrite || returnWhatWasGiven;
const transformedObj = options.transformRead(obj);
// Check that we haven't visited this object before (avoid infinite recursion)
// @ts-ignore
if (objCache.indexOf(transformedObj) > -1) {
return finalArr;
}
// Add object to cache to make sure we don't traverse it twice
// @ts-ignore
objCache.push(transformedObj);
const currentPath = (key: string | number | symbol) => {
// @ts-ignore
const tKey = options.transformKey(key);
return parentPath ? join(parentPath, tKey) : tKey;
};
for (const i in transformedObj) {
if (!transformedObj.hasOwnProperty(i)) continue;
if (options.ignore && options.ignore.test(i)) {
continue;
}
const pathToChild = currentPath(i);
const childObj = transformedObj[i];
finalArr.push(pathToChild);
if (typeof childObj === "object" && childObj !== null) {
flatten(childObj, finalArr, pathToChild, options, objCache);
}
}
return finalArr;
};
/**
* Takes an object and finds all paths, then returns the paths as keys
* and the values of each path as the values.
* @param {ObjectType} obj The object to scan.
* @param {Object=} finalObj An object used to collect the path keys.
* (Do not pass this in directly).
* @param {string=} parentPath The path of the parent object. (Do not
* pass this in directly).
* @param {OptionsType} [options] An options object.
* @param {any[]} [objCache] Internal, do not use.
* @returns {ObjectType} An object containing path keys and their values.
*/
export const flattenValues = (obj: ObjectType, finalObj: object | undefined = {}, parentPath: string | undefined = "", options: OptionsType = {}, objCache = []): ObjectType => {
options.transformRead = options.transformRead || returnWhatWasGiven;
options.transformKey = options.transformKey || returnWhatWasGiven;
options.transformWrite = options.transformWrite || returnWhatWasGiven;
const transformedObj = options.transformRead(obj);
// Check that we haven't visited this object before (avoid infinite recursion)
// @ts-ignore
if (objCache.indexOf(transformedObj) > -1) {
// @ts-ignore
return finalObj;
}
// Add object to cache to make sure we don't traverse it twice
// @ts-ignore
objCache.push(transformedObj);
const currentPath = (i: string, info: any) => {
// @ts-ignore
const tKey = options.transformKey(i, info);
return parentPath ? parentPath + "." + tKey : tKey;
};
for (const i in transformedObj) {
if (transformedObj.hasOwnProperty(i)) {
const type = typeof transformedObj[i];
const info = {
type,
isArrayIndex: Array.isArray(transformedObj),
isFlat: type !== "object" || transformedObj[i] instanceof Date || transformedObj[i] instanceof RegExp
};
const pathKey = currentPath(i, info);
if (!info.isFlat) {
if (transformedObj[i] !== null) {
flattenValues(transformedObj[i], finalObj, pathKey, options, objCache);
}
} else if (options.leavesOnly) {
// Found leaf node!
// @ts-ignore
finalObj[pathKey] = options.transformWrite(transformedObj[i]);
}
if (!options.leavesOnly) {
// @ts-ignore
finalObj[pathKey] = options.transformWrite(transformedObj[i]);
}
}
}
// @ts-ignore
return finalObj;
};
/**
* Joins multiple string arguments into a path string.
* Ignores blank or undefined path parts and also ensures
* that each part is escaped so passing "foo.bar" will
* result in an escaped version.
* @param args args Path to join.
* @returns A final path string.
*/
export const join = (...args: string[]) => {
return args.reduce((arr, item) => {
if (item !== undefined && String(item)) {
// @ts-ignore
arr.push(item);
}
return arr;
}, []).join(".");
};
/**
* Joins multiple string arguments into a path string.
* Ignores blank or undefined path parts and also ensures
* that each part is escaped so passing "foo.bar" will
* result in an escaped version.
* @param {...string} args Path to join.
* @returns {string} A final path string.
*/
export const joinEscaped = (...args: string[]) => {
const escapedArgs = args.map((item) => {
return escape(item);
});
return join(...escapedArgs);
};
/**
* Counts the total number of key leaf nodes in the passed object.
* @param {ObjectType} obj The object to count key leaf nodes for.
* @param {Array=} objCache Do not use. Internal array to track
* visited leafs.
* @returns {number} The number of keys.
*/
export const countLeafNodes = (obj: ObjectType, objCache: Array<any> | undefined = []): number => {
let totalKeys = 0;
// Add object to cache to make sure we don't traverse it twice
objCache.push(obj);
for (const i in obj) {
if (obj.hasOwnProperty(i)) {
if (obj[i] !== undefined) {
if (obj[i] === null || typeof obj[i] !== "object" || objCache.indexOf(obj[i]) > -1) {
totalKeys++;
} else {
totalKeys += countLeafNodes(obj[i], objCache);
}
}
}
}
return totalKeys;
};
/**
* Finds all the leaf nodes for a given object and returns an array of paths
* to them. This is different from `flatten()` in that it only includes leaf
* nodes and will not include every intermediary path traversed to get to a
* leaf node.
* @param {ObjectType} obj The object to traverse.
* @param {string} [parentPath=""] The path to use as a root/base path to
* start scanning for leaf nodes under.
* @param {any[]} [objCache=[]] Internal usage to check for cyclic structures.
* @returns {[]}
*/
export const leafNodes = (obj: ObjectType, parentPath: string = "", objCache: any[] = []): any[] => {
const paths = [];
// Add object to cache to make sure we don't traverse it twice
objCache.push(obj);
for (const i in obj) {
if (obj.hasOwnProperty(i)) {
if (obj[i] !== undefined) {
const currentPath = join(parentPath, i);
if (obj[i] === null || typeof obj[i] !== "object" || objCache.indexOf(obj[i]) > -1) {
paths.push(currentPath);
} else {
paths.push(...leafNodes(obj[i], currentPath, objCache));
}
}
}
}
return paths;
};
/**
* Tests if the passed object has the paths that are specified and that
* a value exists in those paths. MAY NOT BE INFINITE RECURSION SAFE.
* @param {ObjectType} testKeys The object describing the paths to test for.
* @param {ObjectType} testObj The object to test paths against.
* @returns {boolean} True if the object paths exist.
*/
export const hasMatchingPathsInObject = (testKeys: ObjectType, testObj: ObjectType): boolean => {
let result = true;
for (const i in testKeys) {
if (testKeys.hasOwnProperty(i)) {
if (testObj[i] === undefined) {
return false;
}
if (typeof testKeys[i] === "object" && testKeys[i] !== null) {
// Recurse object
result = hasMatchingPathsInObject(testKeys[i], testObj[i]);
// Should we exit early?
if (!result) {
return false;
}
}
}
}
return result;
};
/**
* Tests if the passed object has the paths that are specified and that
* a value exists in those paths and if so returns the number matched.
* MAY NOT BE INFINITE RECURSION SAFE.
* @param {ObjectType} testKeys The object describing the paths to test for.
* @param {ObjectType} testObj The object to test paths against.
* @returns {{matchedKeys: ObjectType, matchedKeyCount: number, totalKeyCount: number}} Stats on the matched keys.
*/
export const countMatchingPathsInObject = (testKeys: ObjectType, testObj: ObjectType): {
matchedKeys: ObjectType,
matchedKeyCount: number,
totalKeyCount: number
} => {
const matchedKeys = {};
let matchData,
matchedKeyCount = 0,
totalKeyCount = 0;
for (const i in testObj) {
if (testObj.hasOwnProperty(i)) {
if (typeof testObj[i] === "object" && testObj[i] !== null) {
// The test / query object key is an object, recurse
matchData = countMatchingPathsInObject(testKeys[i], testObj[i]);
// @ts-ignore
matchedKeys[i] = matchData.matchedKeys;
totalKeyCount += matchData.totalKeyCount;
matchedKeyCount += matchData.matchedKeyCount;
} else {
// The test / query object has a property that is not an object so add it as a key
totalKeyCount++;
// Check if the test keys also have this key and it is also not an object
if (testKeys && testKeys[i] && (typeof testKeys[i] !== "object" || testKeys[i] === null)) {
// @ts-ignore
matchedKeys[i] = true;
matchedKeyCount++;
} else {
// @ts-ignore
matchedKeys[i] = false;
}
}
}
}
return {
matchedKeys,
matchedKeyCount,
totalKeyCount
};
};
/**
* Returns the type from the item passed. Similar to JavaScript's
* built-in typeof except it will distinguish between arrays, nulls
* and objects as well.
* @param item The item to get the type of.
* @returns The string name of the type.
*/
export const type = (item: unknown): ValueType => {
if (item === null) {
return "null";
}
if (Array.isArray(item)) {
return "array";
}
return typeof item;
};
/**
* Determines if the query data exists anywhere inside the source
* data. Will recurse into arrays and objects to find query.
* @param {*} source The source data to check.
* @param {*} query The query data to find.
* @param {OptionsType} [options] An options object.
* @returns {boolean} True if query was matched, false if not.
*/
export const match = (source: any, query: any, options: OptionsType = {}): boolean => {
const sourceType = typeof source;
const queryType = typeof query;
if (sourceType !== queryType) {
return false;
}
if (sourceType !== "object" || source === null) {
// Simple test
return source === query;
}
// The source is an object-like (array or object) structure
const entries = Object.entries(query);
const foundNonMatch = entries.find(([key, val]) => {
// Recurse if type is array or object
if (typeof val === "object" && val !== null) {
return !match(source[key], val);
}
return source[key] !== val;
});
return !foundNonMatch;
};
export interface FindPathReturn {
match: boolean;
path: string[];
}
/**
* Finds all items in `source` that match the structure of `query` and
* returns the path to them as an array of strings.
* @param {*} source The source to test.
* @param {*} query The query to match.
* @param {FindOptionsType} [options] Options object.
* @param {string=""} parentPath Do not use. The aggregated
* path to the current structure in source.
* @returns {Object} Contains match<Boolean> and path<Array>.
*/
export const findPath = (source: any, query: any, options: FindOptionsType = {
maxDepth: Infinity,
currentDepth: 0,
includeRoot: true
}, parentPath = ""): FindPathReturn => {
const resultArr = [];
const sourceType = typeof source;
options = {
maxDepth: Infinity,
currentDepth: 0,
includeRoot: true,
...options
};
if (options.currentDepth !== 0 || (options.currentDepth === 0 && options.includeRoot)) {
if (match(source, query)) {
resultArr.push(parentPath);
}
}
// @ts-ignore
options.currentDepth++;
// @ts-ignore
if (options.currentDepth <= options.maxDepth && sourceType === "object") {
for (let key in source) {
if (source.hasOwnProperty(key)) {
const val = source[key];
// Recurse down object to find more instances
const result = findPath(val, query, options, join(parentPath, key));
// @ts-ignore
if (result.match) {
// @ts-ignore
resultArr.push(...result.path);
}
}
}
}
return {match: resultArr.length > 0, path: resultArr};
};
export interface FindOnePathNoMatchFoundReturn {
match: false;
}
export interface FindOnePathMatchFoundReturn {
match: true;
path: string;
}
export type FindOnePathReturn = FindOnePathNoMatchFoundReturn | FindOnePathMatchFoundReturn;
/**
* Finds the first item that matches the structure of `query`
* and returns the path to it.
* @param {*} source The source to test.
* @param {*} query The query to match.
* @param {FindOptionsType} [options] Options object.
* @param {string=""} parentPath Do not use. The aggregated
* path to the current structure in source.
* @returns {Object} Contains match<boolean> and path<string>.
*/
export const findOnePath = (source: any, query: any, options: FindOptionsType = {
maxDepth: Infinity,
currentDepth: 0,
includeRoot: true
}, parentPath = ""): FindOnePathReturn => {
const sourceType = typeof source;
options = {
maxDepth: Infinity,
currentDepth: 0,
includeRoot: true,
...options
};
if (options.currentDepth !== 0 || (options.currentDepth === 0 && options.includeRoot)) {
if (match(source, query)) {
return {
match: true,
path: parentPath
};
}
}
// @ts-ignore
options.currentDepth++;
// @ts-ignore
if (options.currentDepth <= options.maxDepth && sourceType === "object" && source !== null) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
const val = source[key];
// Recurse down object to find more instances
const subPath = join(parentPath, key);
const result = findOnePath(val, query, options, subPath);
// @ts-ignore
if (result.match) {
return result;
}
}
}
}
return {match: false};
};
/**
* Returns a deduplicated array of strings.
* @param {string[]} keys An array of strings to deduplicate.
* @returns {string[]} The deduplicated array.
*/
export const keyDedup = (keys: string[]): string[] => {
return keys.filter((elem, pos, arr) => {
return arr.indexOf(elem) === pos;
});
};
/**
* Compares two provided objects / arrays and returns and object
* where the keys are dot-notation paths and the values are any
* differences.
*
* e.g.
* {
* "path.to.new.value": {
* "val1": "the value from obj1",
* "val2": "the value from obj2",
* "type1": "string", // the value type from obj1 (see ValueType for supported values)
* "type2": "string", // the value type from obj2 (see ValueType for supported values)
* "difference": "value" // (see DifferenceType for supported values)
* }
* }
*
* If you only want an array of paths to values that have changed
* see the `diff()` function instead.
* @param {ObjectType} obj1 The first object / array to compare.
* @param {ObjectType} obj2 The second object / array to compare.
* @param {DiffOptionsType} [options] Options object
* @param {string|string[]} options.basePath="" The base path from which to check for
* differences. Differences outside the base path will not be
* returned as part of the array of differences. Leave blank to check
* for all differences between the two objects to compare.
* @param {boolean} options.strict=false If strict is true, diff uses strict
* equality to determine difference rather than non-strict equality;
* effectively (=== is strict, == is non-strict).
* @param {number} options.maxDepth=Infinity Specifies the maximum number of
* path subtrees to walk down before returning what we have found.
* For instance, if set to 2, a diff would only check down,
* "someFieldName.anotherField", or "user.name" and would not go
* further down than two fields. If anything in the trees further
* down than this level have changed, the change will not be detected
* and the path will not be included in the resulting diff array.
* @param {boolean} options.exclusive=false If true, only examines obj2's
* data against obj1 rather than diffing against each other. Especially
* useful if obj2 is a partial of obj1, and you only need to know what
* parts of the partial have changed against obj1.
* @param {string} parentPath="" Used internally only.
* @param {never[]} objCache=[] Used internally only.
* @returns {Record<string, DiffValue>} An object where each key is a path to a
* field that holds a different value between the two objects being
* compared and the value of each key is an object holding details of
* the difference.
*/
export const diffValues = (obj1: ObjectType, obj2: ObjectType, options: DiffOptionsType = {}, parentPath: string = "", objCache: never[] = []): Record<string, DiffValue> => {
// Default the various options values
options.basePath = options.basePath || "";
options.strict = options.strict === undefined ? false : options.strict;
options.maxDepth = options.maxDepth === undefined ? Infinity : options.maxDepth;
options.exclusive = options.exclusive === undefined ? false : options.exclusive;
const {
basePath, strict,
maxDepth,
exclusive
} = options;
const paths: Record<string, DiffValue> = {};
if (basePath instanceof Array) {
// We were given an array of paths, check each path
return basePath.reduce((diffVals, individualPath) => {
// Here we find any path that has a *non-equal* result which
// returns true and then returns the index as a positive integer
// that is not -1. If -1 is returned then no non-equal matches
// were found
const result = diffValues(obj1, obj2, {basePath: individualPath, strict, maxDepth, exclusive}, parentPath, objCache);
if (result && Object.keys(result).length) {
diffVals = {...diffVals, ...result};
}
return diffVals;
}, {});
}
const currentPath = join(parentPath, basePath);
const val1 = get(obj1, basePath);
const val2 = get(obj2, basePath);
const type1 = type(val1);
const type2 = type(val2);
if (type1 !== type2) {
if (strict && val1 != val2) {
// Difference in source and comparison types
paths[currentPath] = {val1, val2, type1, type2, difference: "type"};
}
} else if (type1 === "array" && type2 === "array" && val1.length !== val2.length) {
// Difference in source and comparison content
paths[currentPath] = {val1, val2, type1, type2, difference: "value"};
}
const pathParts = currentPath.split(".");
const hasParts = pathParts[0] !== "";
if ((!hasParts || pathParts.length < maxDepth) && typeof val1 === "object" && val1 !== null) {
// Check that we haven't visited this object or array before (avoid infinite recursion)
// @ts-ignore
if (objCache.indexOf(val1) > -1 || objCache.indexOf(val2) > -1) {
return paths;
}
// @ts-ignore
objCache.push(val1);
// @ts-ignore
objCache.push(val2);
// Grab keys from val1 and val2
const val1Keys = Object.keys(val1);
const val2Keys = (typeof val2 === "object" && val2 !== null) ? Object.keys(val2) : [];
let compositeKeys;
if (exclusive) {
// Use only the keys from val2 for determining differences
compo