obj-walker
Version:
Walk or map over objects in a depth-first preorder or postorder manner.
507 lines (506 loc) • 17.1 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.exclude = exports.size = exports.truncate = exports.compact = exports.unflatten = exports.flatten = exports.findNode = exports.mapLeaves = exports.walkieAsync = exports.walkEachAsync = exports.walkie = exports.walkEach = exports.walk = exports.map = exports.walker = exports.SHORT_CIRCUIT = void 0;
const debug_1 = __importDefault(require("debug"));
const lodash_1 = require("lodash");
const fp_1 = __importDefault(require("lodash/fp"));
const util_1 = require("./util");
const debugTruncate = (0, debug_1.default)('obj-walker:truncate');
const debugMap = (0, debug_1.default)('obj-walker:map');
const debugCompact = (0, debug_1.default)('obj-walker:compact');
const debugWalker = (0, debug_1.default)('obj-walker:walker');
const nextNode = (currentNode, entry, isLeaf) => {
const [key, val] = entry;
const { val: currentVal, parents, path } = currentNode;
const nodeParents = [currentVal, ...parents];
const nodePath = [...path, key];
return {
key,
val,
parents: nodeParents,
path: nodePath,
isLeaf: isLeaf(val),
isRoot: false,
};
};
exports.SHORT_CIRCUIT = Symbol('SHORT_CIRCUIT');
const shouldShortCircuit = (x) => x === exports.SHORT_CIRCUIT;
/**
* Walk an object depth-first in a preorder (default) or postorder manner.
* Call walkFn for each node visited. Supports traversing the object in
* arbitrary ways by passing a traverse fn in options. Short circuit traversal
* by returning the exported symbol `SHORT_CIRCUIT`.
*
* Note: this is a low-level function and probably isn't what you want.
*/
const walker = (obj, walkFn, options = {}) => {
debugWalker('Options - %o', options);
let shortCircuit = false;
const { postOrder, jsonCompat, traverse = util_1.defTraverse } = options;
// A leaf is a node that can't be traversed
const isLeaf = fp_1.default.negate(traverse);
// Recursively walk object
const _walk = (node) => {
if (shortCircuit)
return;
// Preorder
if (!postOrder) {
if (shouldShortCircuit(walkFn(node))) {
debugWalker('Short-circuit');
shortCircuit = true;
return;
}
}
const { val } = node;
const next = traverse(val) || [];
for (const entry of Object.entries(next)) {
_walk(nextNode(node, entry, isLeaf));
}
// Postorder
if (postOrder) {
if (shouldShortCircuit(walkFn(node))) {
debugWalker('Short-circuit');
shortCircuit = true;
}
}
};
_walk((0, util_1.getRoot)(obj, jsonCompat));
};
exports.walker = walker;
const mapPre = (obj, mapper, options) => {
debugMap('Preorder');
const traverse = util_1.defTraverse;
const { jsonCompat, shouldSkip } = options;
// A leaf is a node that can't be traversed
const isLeaf = fp_1.default.negate(traverse);
// Recursively walk object
const _walk = (node) => {
const { isRoot, path } = node;
const newVal = mapper(node);
// Should skip value
if (shouldSkip(newVal, node)) {
debugMap('Skipping - %o', newVal);
(0, lodash_1.unset)(obj, path);
return;
}
if (isRoot) {
obj = newVal;
}
else {
(0, lodash_1.set)(obj, path, newVal);
}
const next = traverse(newVal) || [];
for (const entry of Object.entries(next)) {
_walk(nextNode(node, entry, isLeaf));
}
};
_walk((0, util_1.getRoot)(obj, jsonCompat));
return obj;
};
const mapPost = (obj, mapper, options) => {
debugMap('Postorder');
(0, exports.walker)(obj, (node) => {
const { isRoot, path } = node;
const newVal = mapper(node);
// Should skip value
if (options.shouldSkip(newVal, node)) {
debugMap('Skipping %o', newVal);
(0, lodash_1.unset)(obj, path);
return;
}
if (isRoot) {
obj = newVal;
}
else {
(0, lodash_1.set)(obj, path, newVal);
}
}, { ...options, postOrder: true });
return obj;
};
const setMapDefaults = (options) => ({
postOrder: options.postOrder ?? false,
jsonCompat: options.jsonCompat ?? false,
modifyInPlace: options.modifyInPlace ?? false,
shouldSkip: options.shouldSkip ?? util_1.defShouldSkip,
});
/**
* Map over an object modifying values with a fn depth-first in a
* preorder or postorder manner. The output of the mapper fn
* will be traversed if possible when traversing preorder.
*
* By default, nodes will be excluded by returning `undefined`.
* Undefined array values will not be excluded. To customize
* pass a fn for `options.shouldSkip`.
*/
const map = (obj, mapper, options = {}) => {
debugMap('Options - %o', options);
if (!(0, util_1.isObjectOrArray)(obj)) {
debugMap('Not object or array');
return obj;
}
const opts = setMapDefaults(options);
if (!opts.modifyInPlace) {
debugMap('Deep clone');
obj = fp_1.default.cloneDeep(obj);
}
if (options.postOrder) {
return mapPost(obj, mapper, opts);
}
return mapPre(obj, mapper, opts);
};
exports.map = map;
/**
* Walk an object depth-first in a preorder (default) or
* postorder manner. Returns an array of nodes.
*/
const walk = (obj, options = {}) => {
const nodes = [];
const walkFn = (node) => {
nodes.push(node);
};
(0, exports.walker)(obj, walkFn, options);
// Filter the leaves
if (options.leavesOnly) {
return fp_1.default.filter('isLeaf', nodes);
}
return nodes;
};
exports.walk = walk;
/**
* Walk over an object calling `walkFn` for each node. The original
* object is deep-cloned by default making it possible to simply mutate each
* node as needed in order to transform the object. The cloned object
* is returned if `options.modifyInPlace` is not set to true.
*/
const walkEach = (obj, walkFn, options = {}) => {
if (!options.modifyInPlace) {
obj = fp_1.default.cloneDeep(obj);
}
(0, exports.walk)(obj, options).forEach(walkFn);
return obj;
};
exports.walkEach = walkEach;
/**
* @deprecated Use walkEach
*/
exports.walkie = exports.walkEach;
/**
* Like `walkEach` but awaits the promise returned by `walkFn` before proceeding to
* the next node.
*/
const walkEachAsync = async (obj, walkFn, options = {}) => {
if (!options.modifyInPlace) {
obj = fp_1.default.cloneDeep(obj);
}
const nodes = (0, exports.walk)(obj, options);
for (const node of nodes) {
await walkFn(node);
}
return obj;
};
exports.walkEachAsync = walkEachAsync;
/**
* @deprecated Use walkEachAsync
*/
exports.walkieAsync = exports.walkEachAsync;
/**
* Map over the leaves of an object with a fn. By default, nodes will be excluded
* by returning `undefined`. Undefined array values will not be excluded. To customize
* pass a fn for `options.shouldSkip`.
*/
const mapLeaves = (obj, mapper, options = {}) => {
if (!(0, util_1.isObjectOrArray)(obj)) {
return obj;
}
const opts = setMapDefaults(options);
const nodes = (0, exports.walk)(obj, { ...opts, leavesOnly: true });
if (!opts.modifyInPlace) {
obj = fp_1.default.isPlainObject(obj) ? {} : [];
}
for (const node of nodes) {
const newVal = mapper(node);
// Should skip value
if (opts.shouldSkip(newVal, node)) {
continue;
}
(0, lodash_1.set)(obj, node.path, newVal);
}
return obj;
};
exports.mapLeaves = mapLeaves;
/**
* Search for a node and short-circuit the traversal if it's found.
*/
const findNode = (obj, findFn, options = {}) => {
let node;
const walkFn = (n) => {
if (findFn(n)) {
node = n;
return exports.SHORT_CIRCUIT;
}
};
(0, exports.walker)(obj, walkFn, options);
return node;
};
exports.findNode = findNode;
const chunkPath = (path, separator) => {
let nestedPath = [];
const chunkedPath = [];
const addNestedPath = () => {
if (nestedPath.length) {
chunkedPath.push(nestedPath.join(separator));
nestedPath = [];
}
};
for (const key of path) {
if (/[0-9]+/.test(key)) {
addNestedPath();
chunkedPath.push(key);
}
else {
nestedPath.push(key);
}
}
addNestedPath();
return chunkedPath;
};
/**
* Flatten an object's keys. Optionally pass `separator` to determine
* what character to join keys with. Defaults to '.'. If an array is
* passed, an object of path to values is returned unless the `objectsOnly`
* option is set.
*/
const flatten = (obj, options = {}) => {
const nodes = (0, exports.walk)(obj, { ...options, leavesOnly: true });
const separator = options.separator || '.';
const result = Array.isArray(obj) && options.objectsOnly ? [] : {};
for (const node of nodes) {
const path = options.objectsOnly
? chunkPath(node.path, separator)
: [node.path.join(separator)];
(0, lodash_1.set)(result, path, node.val);
}
return result;
};
exports.flatten = flatten;
/**
* Unflatten an object previously flattened. Optionally pass `separator`
* to determine what character or RegExp to split keys with.
* Defaults to '.'.
*/
const unflatten = (obj, options = {}) => {
const separator = options.separator || '.';
return (0, exports.map)(obj, ({ val }) => {
if (fp_1.default.isPlainObject(val)) {
const keyPaths = Object.keys(val).map((key) => key.split(separator));
return fp_1.default.zipObjectDeep(keyPaths, Object.values(val));
}
return val;
});
};
exports.unflatten = unflatten;
const buildCompactFilter = (options) => {
const fns = [];
if (options.removeUndefined) {
fns.push(fp_1.default.isUndefined);
}
if (options.removeNull) {
fns.push(fp_1.default.isNull);
}
if (options.removeEmptyString) {
fns.push((x) => x === '');
}
if (options.removeFalse) {
fns.push((x) => x === false);
}
if (options.removeNaN) {
fns.push(fp_1.default.isNaN);
}
if (options.removeEmptyObject) {
fns.push(fp_1.default.overEvery([fp_1.default.isPlainObject, fp_1.default.isEmpty]));
}
if (options.removeEmptyArray) {
fns.push(fp_1.default.overEvery([fp_1.default.isArray, fp_1.default.isEmpty]));
}
if (options.removeFn) {
fns.push(options.removeFn);
}
return fp_1.default.overSome(fns);
};
const TOMBSTONE = Symbol('TOMBSTONE');
/**
* Compact an object, removing fields recursively according to the supplied options.
* All option flags are `false` by default. If `compactArrays` is set to `true`, arrays
* will be compacted based on the enabled 'remove' option flags.
*/
const compact = (obj, options) => {
const remove = buildCompactFilter(options);
const mapper = (node) => {
let { val } = node;
// Remove all tombstone values
if (options.compactArrays && Array.isArray(val)) {
debugCompact('Remove tombstones');
val = fp_1.default.remove((x) => x === TOMBSTONE, val);
}
if ((0, util_1.parentIsArray)(node)) {
if (options.compactArrays && remove(val, node)) {
debugCompact('Tombstone set');
return TOMBSTONE;
}
return val;
}
if (!remove(val, node)) {
return val;
}
};
return (0, exports.map)(obj, mapper, { ...options, postOrder: true });
};
exports.compact = compact;
/**
* Transform an Error to a plain object.
*/
const transformError = (error) => ({
message: error.message,
name: error.name,
...(error.stack && { stack: error.stack }),
...fp_1.default.toPlainObject(error),
});
/**
* Truncate allows you to limit the depth of nested objects/arrays,
* the length of strings, and the length of arrays. Instances of Error
* can be converted to plain objects so that the enabled truncation options
* also apply to the error fields. All truncation methods are opt-in.
*
* Note: For the best performance you should consider setting `modifyInPlace`
* to `true`.
*
* Inspiration: https://github.com/runk/dtrim
*/
const truncate = (obj, options) => {
const maxDepth = options.maxDepth || Infinity;
const replacementAtMaxDepth = 'replacementAtMaxDepth' in options
? options.replacementAtMaxDepth
: '[Truncated]';
const maxArrayLength = options.maxArrayLength || Infinity;
const maxStringLength = options.maxStringLength || Infinity;
const replacementAtMaxStringLength = options.replacementAtMaxStringLength ?? '...';
// Handle top-level Error object
if (options.transformErrors && obj instanceof Error) {
debugTruncate('Transforming top-level Error');
obj = transformError(obj);
}
return (0, exports.map)(obj, (node) => {
const { path, val, isLeaf } = node;
// Max depth reached
if (!isLeaf && path.length === maxDepth) {
debugTruncate('Max object depth reached - %d', maxDepth);
return replacementAtMaxDepth;
}
// Array exceeds max length
if (Array.isArray(val) && val.length > maxArrayLength) {
debugTruncate('Max array length reached - %d', maxArrayLength);
return val.slice(0, maxArrayLength);
}
// Transform Error to plain object
if (options.transformErrors && val instanceof Error) {
debugTruncate('Transforming Error');
return transformError(val);
}
// String exceeds max length
if (typeof val === 'string' && val.length > maxStringLength) {
debugTruncate('Max string length reached - %d', maxStringLength);
const replacement = typeof replacementAtMaxStringLength === 'string'
? replacementAtMaxStringLength
: replacementAtMaxStringLength(val);
return `${val.slice(0, maxStringLength)}${replacement}`;
}
return val;
}, options);
};
exports.truncate = truncate;
/**
* Inspiration: https://github.com/miktam/sizeof
*/
const getSize = (val) => {
if (typeof val === 'boolean') {
return util_1.ECMA_SIZES.BYTES;
}
else if (typeof val === 'string') {
// Strings are encoded using UTF-16
return val.length * util_1.ECMA_SIZES.STRING;
}
else if (typeof val === 'number') {
// Numbers are 64-bit
return util_1.ECMA_SIZES.NUMBER;
}
else if (typeof val === 'symbol' && val.description) {
return val.description.length * util_1.ECMA_SIZES.STRING;
}
else if (typeof val === 'bigint') {
// NOTE: There is no accurate way to get the actual byte size for bigint
// https://stackoverflow.com/a/54298760/1242923
return util_1.ECMA_SIZES.NUMBER;
}
return 0;
};
/**
* Estimate the size in bytes.
*/
const size = (val) => {
if ((0, util_1.isObjectOrArray)(val)) {
let bytes = 0;
(0, exports.walker)(val, ({ isLeaf, val }) => {
if (isLeaf) {
bytes += getSize(val);
}
});
return bytes;
}
return getSize(val);
};
exports.size = size;
/**
* Check if a path starts with a pattern prefix (with star wildcards).
* A star (*) matches any single field name.
*/
const pathMatchesPrefix = (path, pattern) => {
if (path.length < pattern.length) {
return false;
}
for (let i = 0; i < pattern.length; i++) {
if (pattern[i] !== '*' && pattern[i] !== path[i]) {
return false;
}
}
return true;
};
/**
* Exclude paths from an object. Supports star patterns where '*' matches
* any single field name. For example, 'documents.*.fileName' will match
* 'documents.0.fileName', 'documents.1.fileName', etc.
*
* Also supports prefix matching: excluding 'documents' will exclude
* 'documents.fileName', 'documents.0.fileName', etc.
*/
const exclude = (obj, paths, options = {}) => {
if (!(0, util_1.isObjectOrArray)(obj)) {
return obj;
}
// Parse path patterns (split by '.')
const patterns = paths.map((p) => p.split('.'));
const mapper = (node) => {
const { path, val } = node;
// Check if current path matches any exclude pattern (exact or prefix)
for (const pattern of patterns) {
if (pathMatchesPrefix(path, pattern)) {
return undefined;
}
}
return val;
};
return (0, exports.map)(obj, mapper, options);
};
exports.exclude = exclude;