UNPKG

obj-walker

Version:

Walk or map over objects in a depth-first preorder or postorder manner.

507 lines (506 loc) 17.1 kB
"use strict"; 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;