UNPKG

obj-walker

Version:

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

1,437 lines (1,405 loc) 42.7 kB
import _ from 'lodash/fp' import { describe, expect, test } from 'vitest' import { Node } from './types' import { parentIsArray } from './util' import { compact, exclude, findNode, flatten, map, mapLeaves, SHORT_CIRCUIT, size, truncate, unflatten, walk, walkEach, walkEachAsync, walker, } from './walker' describe('walker', () => { test('remove empty elements from an array (nested)', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, null, 30, [31, undefined, 32], 40] }, g: [25, '', { h: [null, 26, 27] }], i: 'Frank', } const walkFn = (node: Node) => { const { key, val, parents } = node const parent = parents[0] if (Array.isArray(val) && key) { parent[key] = _.compact(val) } } walker(obj, walkFn, { postOrder: true }) expect(obj).toEqual({ a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 30, [31, 32], 40] }, g: [25, { h: [26, 27] }], i: 'Frank', }) }) test('should short-circuit', () => { const obj = { a: { b: 23, c: 24, d: { e: 25 } } } let numNodesVisited = 0 const walkFn = (node: Node) => { numNodesVisited++ const { val } = node if (val === 24) { return SHORT_CIRCUIT } } walker(obj, walkFn) expect(numNodesVisited).toBe(4) }) }) describe('walk', () => { test('walk with no options', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } } const result = walk(obj) expect(result).toEqual([ { key: undefined, parents: [], val: { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }, path: [], isRoot: true, isLeaf: false, }, { key: 'a', val: { b: 23, c: 24 }, parents: [{ a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }], path: ['a'], isLeaf: false, isRoot: false, }, { key: 'b', val: 23, parents: [ { b: 23, c: 24 }, { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }, ], path: ['a', 'b'], isLeaf: true, isRoot: false, }, { key: 'c', val: 24, parents: [ { b: 23, c: 24 }, { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }, ], path: ['a', 'c'], isLeaf: true, isRoot: false, }, { key: 'd', val: { e: 'Bob', f: [10, 20, 30] }, parents: [{ a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }], path: ['d'], isLeaf: false, isRoot: false, }, { key: 'e', val: 'Bob', parents: [ { e: 'Bob', f: [10, 20, 30] }, { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }, ], path: ['d', 'e'], isLeaf: true, isRoot: false, }, { key: 'f', val: [10, 20, 30], parents: [ { e: 'Bob', f: [10, 20, 30] }, { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }, ], path: ['d', 'f'], isLeaf: false, isRoot: false, }, { key: '0', val: 10, parents: [ [10, 20, 30], { e: 'Bob', f: [10, 20, 30] }, { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }, ], path: ['d', 'f', '0'], isLeaf: true, isRoot: false, }, { key: '1', val: 20, parents: [ [10, 20, 30], { e: 'Bob', f: [10, 20, 30] }, { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }, ], path: ['d', 'f', '1'], isLeaf: true, isRoot: false, }, { key: '2', val: 30, parents: [ [10, 20, 30], { e: 'Bob', f: [10, 20, 30] }, { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } }, ], path: ['d', 'f', '2'], isLeaf: true, isRoot: false, }, ]) }) test('walk preorder', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } } const result = walk(obj).map((x) => x.path) expect(result).toEqual([ [], ['a'], ['a', 'b'], ['a', 'c'], ['d'], ['d', 'e'], ['d', 'f'], ['d', 'f', '0'], ['d', 'f', '1'], ['d', 'f', '2'], ]) }) test('walk postorder', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 20, 30] } } const result = walk(obj, { postOrder: true }).map((x) => x.path) expect(result).toEqual([ ['a', 'b'], ['a', 'c'], ['a'], ['d', 'e'], ['d', 'f', '0'], ['d', 'f', '1'], ['d', 'f', '2'], ['d', 'f'], ['d'], [], ]) }) test('walk with custom traverse fn', () => { const obj = { bsonType: 'object', additionalProperties: false, required: ['name', 'type'], properties: { _id: { bsonType: 'objectId' }, name: { bsonType: 'string' }, numberOfEmployees: { bsonType: 'string', enum: ['1 - 5', '6 - 20', '21 - 50', '51 - 200', '201 - 500', '500+'], }, addresses: { bsonType: 'array', items: { bsonType: 'object', additionalProperties: false, properties: { address: { bsonType: 'object', additionalProperties: false, properties: { street: { bsonType: 'string' }, city: { bsonType: 'string' }, county: { bsonType: 'string' }, state: { bsonType: 'string' }, zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, name: { bsonType: 'string' }, isPrimary: { bsonType: 'bool' }, }, }, }, integrations: { bsonType: 'object', additionalProperties: true, properties: { stripe: { bsonType: 'object', additionalProperties: true, properties: { priceId: { bsonType: 'string' }, subscriptionStatus: { bsonType: 'string' }, }, }, }, }, }, } const traverse = (x: any) => x.properties || (x.items && { items: x.items }) const kvs = walk(obj, { traverse }).map(_.pick(['key', 'val'])) expect(kvs).toEqual([ { key: undefined, val: { bsonType: 'object', additionalProperties: false, required: ['name', 'type'], properties: { _id: { bsonType: 'objectId' }, name: { bsonType: 'string' }, numberOfEmployees: { bsonType: 'string', enum: [ '1 - 5', '6 - 20', '21 - 50', '51 - 200', '201 - 500', '500+', ], }, addresses: { bsonType: 'array', items: { bsonType: 'object', additionalProperties: false, properties: { address: { bsonType: 'object', additionalProperties: false, properties: { street: { bsonType: 'string' }, city: { bsonType: 'string' }, county: { bsonType: 'string' }, state: { bsonType: 'string' }, zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, name: { bsonType: 'string' }, isPrimary: { bsonType: 'bool' }, }, }, }, integrations: { bsonType: 'object', additionalProperties: true, properties: { stripe: { bsonType: 'object', additionalProperties: true, properties: { priceId: { bsonType: 'string' }, subscriptionStatus: { bsonType: 'string' }, }, }, }, }, }, }, }, { key: '_id', val: { bsonType: 'objectId' } }, { key: 'name', val: { bsonType: 'string' } }, { key: 'numberOfEmployees', val: { bsonType: 'string', enum: ['1 - 5', '6 - 20', '21 - 50', '51 - 200', '201 - 500', '500+'], }, }, { key: 'addresses', val: { bsonType: 'array', items: { bsonType: 'object', additionalProperties: false, properties: { address: { bsonType: 'object', additionalProperties: false, properties: { street: { bsonType: 'string' }, city: { bsonType: 'string' }, county: { bsonType: 'string' }, state: { bsonType: 'string' }, zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, name: { bsonType: 'string' }, isPrimary: { bsonType: 'bool' }, }, }, }, }, { key: 'items', val: { bsonType: 'object', additionalProperties: false, properties: { address: { bsonType: 'object', additionalProperties: false, properties: { street: { bsonType: 'string' }, city: { bsonType: 'string' }, county: { bsonType: 'string' }, state: { bsonType: 'string' }, zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, name: { bsonType: 'string' }, isPrimary: { bsonType: 'bool' }, }, }, }, { key: 'address', val: { bsonType: 'object', additionalProperties: false, properties: { street: { bsonType: 'string' }, city: { bsonType: 'string' }, county: { bsonType: 'string' }, state: { bsonType: 'string' }, zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, }, { key: 'street', val: { bsonType: 'string' } }, { key: 'city', val: { bsonType: 'string' } }, { key: 'county', val: { bsonType: 'string' } }, { key: 'state', val: { bsonType: 'string' } }, { key: 'zip', val: { bsonType: 'string' } }, { key: 'country', val: { bsonType: 'string' } }, { key: 'name', val: { bsonType: 'string' } }, { key: 'isPrimary', val: { bsonType: 'bool' } }, { key: 'integrations', val: { bsonType: 'object', additionalProperties: true, properties: { stripe: { bsonType: 'object', additionalProperties: true, properties: { priceId: { bsonType: 'string' }, subscriptionStatus: { bsonType: 'string' }, }, }, }, }, }, { key: 'stripe', val: { bsonType: 'object', additionalProperties: true, properties: { priceId: { bsonType: 'string' }, subscriptionStatus: { bsonType: 'string' }, }, }, }, { key: 'priceId', val: { bsonType: 'string' } }, { key: 'subscriptionStatus', val: { bsonType: 'string' } }, ]) }) test('reduce a tree', () => { const obj = { joe: { age: 16, courses: { math: { scores: [95, 96, 87] }, biology: { scores: [97, 94, 87] }, }, }, bob: { age: 16, courses: { math: { scores: [88, 87, 75] }, biology: { scores: [97, 94, 87] }, }, }, frank: { age: 15, courses: { math: { scores: [90, 85, 73] }, biology: { scores: [89, 87, 73] }, }, }, } const nodes = walk(obj, { traverse: (x: any) => _.isPlainObject(x) && x }) const avg = _.flow( _.filter({ key: 'scores' }), _.flatMap('val'), _.mean, _.round )(nodes) expect(avg).toBe(88) }) }) describe('findNode', () => { const obj = { name: 'Joe', address: { city: 'New York', state: 'NY', zipCode: '10001' }, likes: ['Stock Market', 'Running'], } test('should find a node and short-circuit', () => { let numNodesVisited = 0 const node = findNode(obj, (node) => { numNodesVisited++ return _.isEqual(node.path, ['address', 'zipCode']) }) expect(node).toEqual({ key: 'zipCode', val: '10001', parents: [ { city: 'New York', state: 'NY', zipCode: '10001' }, { name: 'Joe', address: { city: 'New York', state: 'NY', zipCode: '10001' }, likes: ['Stock Market', 'Running'], }, ], path: ['address', 'zipCode'], isLeaf: true, isRoot: false, }) expect(numNodesVisited).toBe(6) }) test('should return undefined if not found', () => { const node = findNode(obj, (node) => node.key === 'countryCode') expect(node).toBeUndefined() }) test('should throw if an exception is throw in findFn', () => { expect(() => { findNode(obj, () => { throw 'fail' }) }).toThrow() }) }) describe('walkEach', () => { test('mutate a tree', () => { const obj = { bsonType: 'object', additionalProperties: false, required: ['name'], properties: { _id: { bsonType: 'objectId' }, name: { bsonType: 'string' }, addresses: { bsonType: 'array', items: { bsonType: 'object', additionalProperties: false, properties: { address: { bsonType: 'object', additionalProperties: false, properties: { zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, }, }, }, }, } const traverse = (x: any) => x.properties || (x.items && { items: x.items }) const walkFn = ({ val }: Node) => { if ('additionalProperties' in val) { val.additionalProperties = true } } const newObj = walkEach(obj, walkFn, { traverse }) // Objects are not the same expect(obj).not.toBe(newObj) // additionalProperties set to true recursively expect(newObj).toEqual({ bsonType: 'object', additionalProperties: true, required: ['name'], properties: { _id: { bsonType: 'objectId' }, name: { bsonType: 'string' }, addresses: { bsonType: 'array', items: { bsonType: 'object', additionalProperties: true, properties: { address: { bsonType: 'object', additionalProperties: true, properties: { zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, }, }, }, }, }) }) test('modifyInPlace', () => { const obj = { joe: { scores: [90, 80, 75, 95] }, bob: { scores: [95, 87, 92, 88] }, frank: { scores: [96, 86, 91, 84] }, tom: null, } const result = walkEach( obj, ({ val }) => { if (_.isPlainObject(val) && 'scores' in val) { val.avg = _.sum(val.scores) / val.scores.length } }, { modifyInPlace: true } ) expect(obj).toBe(result) expect(result).toEqual({ joe: { scores: [90, 80, 75, 95], avg: 85 }, bob: { scores: [95, 87, 92, 88], avg: 90.5 }, frank: { scores: [96, 86, 91, 84], avg: 89.25 }, tom: null, }) }) }) describe('walkEachAsync', () => { test('should await walkFn', async () => { const obj = { joe: { scores: [90, 80, 75, 95] }, bob: { scores: [95, 87, 92, 88] }, frank: { scores: [96, 86, 91, 84] }, tom: null, } const result = await walkEachAsync(obj, async ({ val }) => { if (_.isPlainObject(val) && 'scores' in val) { val.avg = _.sum(val.scores) / val.scores.length } }) expect(obj).not.toBe(result) expect(result).toEqual({ joe: { scores: [90, 80, 75, 95], avg: 85 }, bob: { scores: [95, 87, 92, 88], avg: 90.5 }, frank: { scores: [96, 86, 91, 84], avg: 89.25 }, tom: null, }) }) }) describe('map', () => { test('preorder', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, null, 30, [31, undefined, 32], 40] }, g: [25, '', { h: [null, 26, 27] }], i: 'Frank', } const result = map(obj, ({ val }) => Array.isArray(val) ? _.compact(val) : val ) expect(obj).toEqual(obj) expect(obj).not.toBe(result) expect(result).toEqual({ a: { b: 23, c: 24 }, d: { e: 'Bob', f: [10, 30, [31, 32], 40] }, g: [25, { h: [26, 27] }], i: 'Frank', }) }) test('preorder - top-level mapping', () => { const obj = { bsonType: 'object', additionalProperties: false, required: ['name'], properties: { _id: { bsonType: 'objectId' }, name: { bsonType: 'string' }, addresses: { bsonType: 'array', items: { bsonType: 'object', additionalProperties: false, properties: { address: { bsonType: 'object', additionalProperties: false, properties: { zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, }, }, }, }, } const convertSchemaNode = (obj: Record<string, any>) => { return { ..._.pick(['properties'], obj), ...(obj.bsonType !== 'object' && { type: obj.bsonType }), } } const mapper = (node: Node) => { const { key, val, parents } = node // Ignore top-level _id field if (key === '_id' && parents.length === 2) { return } if (val?.bsonType) { if (val.bsonType === 'array') { return convertSchemaNode(val.items) } return convertSchemaNode(val) } return val } expect(map(obj, mapper)).toEqual({ properties: { name: { type: 'string' }, addresses: { properties: { address: { properties: { zip: { type: 'string' }, country: { type: 'string' }, }, }, }, }, }, }) }) test('postorder', () => { const obj = { bob: { scores: ['87', 'x97', 95, false] }, joe: { scores: [92, 92.5, '73.2', ''] }, frank: { scores: ['abc', ''] }, } const result = map( obj, ({ val, isLeaf }) => { if (isLeaf) { return parseFloat(val) } return Array.isArray(val) ? _.compact(val) : val }, { postOrder: true } ) // Objects are not the same expect(obj).not.toBe(result) expect(result).toEqual({ bob: { scores: [87, 95] }, joe: { scores: [92, 92.5, 73.2] }, frank: { scores: [] }, }) }) test('postorder - top-level mapping', () => { const obj = ['87', 95, 'foo'] const result = map( obj, ({ val, isLeaf }) => { if (isLeaf) { return parseFloat(val) } return Array.isArray(val) ? _.compact(val) : val }, { postOrder: true } ) expect(result).toEqual([87, 95]) }) test('custom shouldSkip fn', () => { const obj = { bob: { scores: ['87', 'x97', 95, false] }, joe: { scores: [92, 92.5, '73.2', ''] }, frank: { scores: ['abc', ''] }, } const shouldSkip = (val: any, node: Node) => _.isEmpty(val) && !parentIsArray(node) const result = map( obj, ({ val, isLeaf }) => { if (isLeaf) { return parseFloat(val) } return Array.isArray(val) ? _.compact(val) : val }, { postOrder: true, shouldSkip } ) expect(obj).toEqual(obj) expect(result).toEqual({ bob: { scores: [87, 95] }, joe: { scores: [92, 92.5, 73.2] }, }) }) test('exclude nodes', () => { const obj = { joe: { age: 42, username: 'joe blow', password: '1234' }, frank: { age: 39, username: 'frankenstein', password: 'password' }, } const result = map(obj, ({ key, val }) => { if (key !== 'password') { return val } }) expect(obj).toEqual(obj) expect(result).toEqual({ joe: { age: 42, username: 'joe blow' }, frank: { age: 39, username: 'frankenstein' }, }) }) test('map while modifying in place', () => { const obj = { bob: { scores: ['87', 'x97', 95, false] }, joe: { scores: [92, 92.5, '73.2', ''] }, frank: { scores: ['abc', ''] }, } const result = map( obj, ({ val, isLeaf }) => { if (isLeaf) { return parseFloat(val) } return Array.isArray(val) ? _.compact(val) : val }, { postOrder: true, modifyInPlace: true } ) // Objects are the same expect(obj).toBe(result) expect(result).toEqual({ bob: { scores: [87, 95] }, joe: { scores: [92, 92.5, 73.2] }, frank: { scores: [] }, }) }) }) describe('mapLeaves', () => { test('should increment leave values', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 100, f: [10, 20, 30] } } const result = mapLeaves(obj, ({ val }) => val + 1) expect(obj).toEqual(obj) expect(result).toEqual({ a: { b: 24, c: 25 }, d: { e: 101, f: [11, 21, 31] }, }) }) test('should modify in place', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 100, f: [10, 20, 30] } } const result = mapLeaves(obj, ({ val }) => val + 1, { modifyInPlace: true }) expect(obj).toBe(result) expect(result).toEqual({ a: { b: 24, c: 25 }, d: { e: 101, f: [11, 21, 31] }, }) }) }) describe('flatten', () => { test('should flatten object', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 100, f: [10, 20, { g: 30, h: { i: 40 } }] }, } const result = flatten(obj) expect(result).toEqual({ 'a.b': 23, 'a.c': 24, 'd.e': 100, 'd.f.0': 10, 'd.f.1': 20, 'd.f.2.g': 30, 'd.f.2.h.i': 40, }) }) test('should flatten object with objectsOnly set to true', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 100, f: [10, 20, { g: 30, h: { i: 40 } }] }, } const result = flatten(obj, { objectsOnly: true }) expect(result).toEqual({ 'a.b': 23, 'a.c': 24, 'd.e': 100, 'd.f': [10, 20, { g: 30, 'h.i': 40 }], }) }) test('should flatten array', () => { const arr = [10, 20, { a: { b: 20, c: 30, d: [40, { e: { f: 50 } }] } }] const result = flatten(arr) expect(result).toEqual({ '0': 10, '1': 20, '2.a.b': 20, '2.a.c': 30, '2.a.d.0': 40, '2.a.d.1.e.f': 50, }) }) test('should preserve top-level array with objectsOnly set to true', () => { const arr = [10, 20, { a: { b: 20, c: 30, d: [40, { e: { f: 50 } }] } }] const result = flatten(arr, { objectsOnly: true }) expect(result).toEqual([ 10, 20, { 'a.b': 20, 'a.c': 30, 'a.d': [40, { 'e.f': 50 }] }, ]) }) test('should flatten with custom traversal and custom separator', () => { const obj = { bsonType: 'object', additionalProperties: false, required: ['name', 'type'], properties: { _id: { bsonType: 'objectId' }, name: { bsonType: 'string' }, numberOfEmployees: { bsonType: 'string', enum: ['1 - 5', '6 - 20', '21 - 50', '51 - 200', '201 - 500', '500+'], }, addresses: { bsonType: 'array', items: { bsonType: 'object', additionalProperties: false, properties: { address: { bsonType: 'object', additionalProperties: false, properties: { street: { bsonType: 'string' }, city: { bsonType: 'string' }, county: { bsonType: 'string' }, state: { bsonType: 'string' }, zip: { bsonType: 'string' }, country: { bsonType: 'string' }, }, }, name: { bsonType: 'string' }, isPrimary: { bsonType: 'bool' }, }, }, }, integrations: { bsonType: 'object', additionalProperties: true, properties: { stripe: { bsonType: 'object', additionalProperties: true, properties: { priceId: { bsonType: 'string' }, subscriptionStatus: { bsonType: 'string' }, }, }, }, }, }, } const traverse = (x: any) => x.properties || (x.items && { items: x.items }) const result = flatten(obj, { traverse, separator: '_' }) expect(result).toEqual({ _id: { bsonType: 'objectId' }, name: { bsonType: 'string' }, numberOfEmployees: { bsonType: 'string', enum: ['1 - 5', '6 - 20', '21 - 50', '51 - 200', '201 - 500', '500+'], }, addresses_items_address_street: { bsonType: 'string' }, addresses_items_address_city: { bsonType: 'string' }, addresses_items_address_county: { bsonType: 'string' }, addresses_items_address_state: { bsonType: 'string' }, addresses_items_address_zip: { bsonType: 'string' }, addresses_items_address_country: { bsonType: 'string' }, addresses_items_name: { bsonType: 'string' }, addresses_items_isPrimary: { bsonType: 'bool' }, integrations_stripe_priceId: { bsonType: 'string' }, integrations_stripe_subscriptionStatus: { bsonType: 'string' }, }) }) }) describe('unflatten', () => { test('should unflatten object with object and array paths', () => { const obj = { 'a.b': 23, 'a.c': 24, 'd.e': 100, 'd.f.0': 10, 'd.f.1': 20, 'd.f.2.g': 30, 'd.f.2.h.i': 40, } const result = unflatten(obj) expect(result).toEqual({ a: { b: 23, c: 24 }, d: { e: 100, f: [10, 20, { g: 30, h: { i: 40 } }] }, }) }) test('should unflatten object with custom separator', () => { const obj = { a_b: 23, a_c: 24, d_e: 100, d_f_0: 10, d_f_1: 20, d_f_2_g: 30, d_f_2_h_i: 40, } const result = unflatten(obj, { separator: '_' }) expect(result).toEqual({ a: { b: 23, c: 24 }, d: { e: 100, f: [10, 20, { g: 30, h: { i: 40 } }] }, }) }) test('should unflatten a nested array/object', () => { const arr = [10, 20, { 'a.b': 20, 'a.c': 30, 'a.d': [40, { 'e.f': 50 }] }] const result = unflatten(arr) expect(result).toEqual([ 10, 20, { a: { b: 20, c: 30, d: [40, { e: { f: 50 } }] } }, ]) }) test('should preserve a non-flattened object', () => { const obj = { a: { b: { c: 10, d: 20 }, e: [30] } } const result = unflatten(obj) expect(result).toEqual({ a: { b: { c: 10, d: 20 }, e: [30] } }) }) }) describe('compact', () => { test('should remove undefined', () => { const obj = { a: { b: undefined }, c: undefined, d: 42, e: [undefined] } const result = compact(obj, { removeUndefined: true }) expect(result).toEqual({ a: {}, d: 42, e: [undefined] }) // Objects are not the same expect(obj).not.toBe(result) }) test('should remove null', () => { const obj = { a: { b: null }, c: null, d: 42, e: [null] } const result = compact(obj, { removeNull: true }) expect(result).toEqual({ a: {}, d: 42, e: [null] }) }) test('should remove empty string', () => { const obj = { a: { b: '' }, c: '', d: 42, e: [''] } const result = compact(obj, { removeEmptyString: true }) expect(result).toEqual({ a: {}, d: 42, e: [''] }) }) test('should remove false', () => { const obj = { a: { b: false }, c: false, d: 42, e: [false] } const result = compact(obj, { removeFalse: true }) expect(result).toEqual({ a: {}, d: 42, e: [false] }) }) test('should remove NaN', () => { const obj = { a: { b: NaN }, c: NaN, d: 42, e: [NaN] } const result = compact(obj, { removeNaN: true }) expect(result).toEqual({ a: {}, d: 42, e: [NaN] }) }) test('should remove using remove fn', () => { const obj = { a: { b: 'null' }, c: 'null', d: 42, e: ['null'] } const result = compact(obj, { removeFn: (val: any) => val === 'null' }) expect(result).toEqual({ a: {}, d: 42, e: ['null'] }) }) test('should remove using remove fn based on second param', () => { const obj = { a: { b: 'null' }, c: 'null' } const result = compact(obj, { removeFn: (_val: any, node: Node) => _.isEqual(node.path, ['a', 'b']), }) expect(result).toEqual({ a: {}, c: 'null' }) }) test('should remove array elem using remove fn based on second param', () => { const obj = { joe: { grades: [90, 85, 92] }, bob: { grades: [89, 87, 94] } } const result = compact(obj, { removeFn: (_val: any, node: Node) => node.key === '0', compactArrays: true, }) expect(result).toEqual({ joe: { grades: [85, 92] }, bob: { grades: [87, 94] }, }) }) test('should remove empty object', () => { const obj = { a: { b: {} }, c: {}, d: 42 } const result = compact(obj, { removeEmptyObject: true }) expect(result).toEqual({ d: 42 }) }) test('should remove empty array', () => { const obj = { a: { b: [] }, c: [], d: 42 } const result = compact(obj, { removeEmptyArray: true }) expect(result).toEqual({ a: {}, d: 42 }) }) test('should compact arrays', () => { const obj = { a: { b: [null, null] }, c: [], d: [42, null, ''] } const result = compact(obj, { removeNull: true, compactArrays: true }) expect(result).toEqual({ a: { b: [] }, c: [], d: [42, ''] }) }) test('should compact and remove empty arrays', () => { const obj = { a: { b: [null, null] }, c: [], d: [42, null] } const result = compact(obj, { removeNull: true, compactArrays: true, removeEmptyArray: true, }) expect(result).toEqual({ a: {}, d: [42] }) }) test('should compact top-level array', () => { const arr = ['', 2, null, 3, {}, 4, [[undefined]], 5] const result = compact(arr, { removeUndefined: true, removeEmptyString: true, removeNull: true, removeEmptyObject: true, removeEmptyArray: true, compactArrays: true, }) expect(result).toEqual([2, 3, 4, 5]) }) test('should compact all the things', () => { const obj = { a: { b: [null, null, 21, '', { b1: null }, { b2: 26 }] }, c: [], d: [42, null], e: { f: { g: '', h: undefined, i: 'null' } }, } const result = compact(obj, { removeUndefined: true, removeEmptyString: true, removeNull: true, compactArrays: true, removeEmptyArray: true, removeEmptyObject: true, removeFn: (val: any) => val === 'null', }) expect(result).toEqual({ a: { b: [21, { b2: 26 }] }, d: [42] }) }) test('should compact while modifying object in place', () => { const obj = { a: { b: undefined }, c: undefined, d: 42, e: [undefined] } const result = compact(obj, { removeUndefined: true, modifyInPlace: true }) expect(result).toEqual({ a: {}, d: 42, e: [undefined] }) // Objects are the same expect(obj).toBe(result) }) }) describe('truncate', () => { test('should truncate depth 1', () => { const obj = { a: { b: 'Frank' }, c: 'Bob', d: 42, e: null } const result = truncate(obj, { maxDepth: 1 }) expect(result).toEqual({ a: '[Truncated]', c: 'Bob', d: 42, e: null }) // Objects are not the same expect(obj).not.toBe(result) }) test('should truncate depth 2', () => { const obj = { a: { b: 'Frank', c: { d: 'Joe' }, e: null }, f: 42 } const result = truncate(obj, { maxDepth: 2 }) expect(result).toEqual({ a: { b: 'Frank', c: '[Truncated]', e: null }, f: 42, }) }) test('should truncate arrays', () => { const obj = { a: { b: 'Frank', c: { d: ['Bob', { name: 'Joe' }, 'Tom'] }, e: null }, f: 42, } const result = truncate(obj, { maxDepth: 4 }) expect(result).toEqual({ a: { b: 'Frank', c: { d: ['Bob', '[Truncated]', 'Tom'] }, e: null }, f: 42, }) }) test('should allow custom replacement text', () => { const obj = { a: { b: 'Frank' }, c: 'Bob', d: 42, e: null } const result = truncate(obj, { maxDepth: 1, replacementAtMaxDepth: null }) expect(result).toEqual({ a: null, c: 'Bob', d: 42, e: null }) }) test('should truncate and modify object in place', () => { const obj = { a: { b: 'Frank' }, c: 'Bob', d: 42, e: null } const result = truncate(obj, { maxDepth: 1, modifyInPlace: true }) expect(result).toEqual({ a: '[Truncated]', c: 'Bob', d: 42, e: null }) // Objects are the same expect(obj).toBe(result) }) test('should handle Error', () => { class ValidationError extends Error { context: object constructor(message: any, context: object) { super(message) this.name = 'ValidationError' this.context = context } } const context = { a: { b: { c: { d: 'missing' } } } } const error = new ValidationError('failure', context) const obj = { error } const result = truncate(obj, { maxDepth: 5, transformErrors: true }) expect(result).toMatchObject({ error: { message: 'failure', name: 'ValidationError', context: { a: { b: { c: '[Truncated]' } } }, }, }) }) test('should handle top-level Error', () => { class ValidationError extends Error { context: object constructor(message: any, context: object) { super(message) this.name = 'ValidationError' this.context = context } } const context = { a: { b: { c: { d: 'missing' } } } } const error = new ValidationError('failure', context) const obj = error const result = truncate(obj, { maxDepth: 4, transformErrors: true }) expect(result).toMatchObject({ message: 'failure', name: 'ValidationError', context: { a: { b: { c: '[Truncated]' } } }, }) }) test('should truncate strings', () => { const obj = { a: { b: '1234567890' }, c: '123', d: 42, e: null } const result = truncate(obj, { maxStringLength: 5 }) expect(result).toEqual({ a: { b: '12345...' }, c: '123', d: 42, e: null }) }) test('should truncate strings with custom replacement text', () => { const obj = { a: { b: '1234567890' }, c: '123', d: 42, e: null } const result = truncate(obj, { maxStringLength: 5, replacementAtMaxStringLength: '', }) expect(result).toEqual({ a: { b: '12345' }, c: '123', d: 42, e: null }) }) test('should truncate strings with custom replacement function', () => { const obj = { a: { b: '1234567890' }, c: '123', d: 42, e: null } const result = truncate(obj, { maxStringLength: 5, replacementAtMaxStringLength: (input: string) => ` [Length: ${input.length}]`, }) expect(result).toEqual({ a: { b: '12345 [Length: 10]' }, c: '123', d: 42, e: null, }) }) test('should truncate arrays', () => { const obj = { a: [1, 2, 3, 4, 5], c: '123', d: [1, 2], e: null } const result = truncate(obj, { maxArrayLength: 3 }) expect(result).toEqual({ a: [1, 2, 3], c: '123', d: [1, 2], e: null }) }) }) describe('size', () => { test('should return number of bytes for strings', () => { const obj = { a: { b: 'hello' } } const result = size(obj) expect(result).toBe(10) }) test('should return number of bytes for symbols', () => { const obj = { a: { b: Symbol('hello') } } const result = size(obj) expect(result).toBe(10) }) test('should return number of bytes for booleans', () => { const obj = { a: { b: [true, false] } } const result = size(obj) expect(result).toBe(8) }) test('should return number of bytes for numbers', () => { const obj = { a: { b: [42, 10n] } } const result = size(obj) expect(result).toBe(16) }) test('should return number of bytes for mixed types', () => { const obj = { a: { b: 'hello' }, c: Symbol('hello'), d: { e: [true, false] }, f: [42, 10n], } const result = size(obj) expect(result).toBe(44) }) test('should handle scalar value', () => { const result = size('hello') expect(result).toBe(10) }) test('should handle top-level array', () => { const result = size(['joe', 'frank']) expect(result).toBe(16) }) test('should return 0 bytes if there are no leaf nodes', () => { const obj = { a: { b: {} } } const result = size(obj) expect(result).toBe(0) }) }) describe('exclude', () => { test('should exclude a simple path', () => { const obj = { a: 1, b: 2, c: 3 } const result = exclude(obj, ['b']) expect(result).toEqual({ a: 1, c: 3 }) }) test('should exclude nested paths', () => { const obj = { a: { b: 23, c: 24 }, d: { e: 'Bob', f: 'Alice' } } const result = exclude(obj, ['a.b', 'd.e']) expect(result).toEqual({ a: { c: 24 }, d: { f: 'Alice' } }) }) test('should exclude paths with star patterns in arrays', () => { const obj = { documents: [ { fileName: 'doc1.txt', size: 100 }, { fileName: 'doc2.txt', size: 200 }, { fileName: 'doc3.txt', size: 300 }, ], } const result = exclude(obj, ['documents.*.fileName']) expect(result).toEqual({ documents: [{ size: 100 }, { size: 200 }, { size: 300 }], }) }) test('should exclude multiple paths with star patterns', () => { const obj = { users: [ { name: 'Alice', password: 'secret1', email: 'alice@example.com' }, { name: 'Bob', password: 'secret2', email: 'bob@example.com' }, ], admin: { name: 'Admin', password: 'admin123' }, } const result = exclude(obj, ['users.*.password', 'admin.password']) expect(result).toEqual({ users: [ { name: 'Alice', email: 'alice@example.com' }, { name: 'Bob', email: 'bob@example.com' }, ], admin: { name: 'Admin' }, }) }) test('should handle star patterns at different levels', () => { const obj = { level1: { level2a: { target: 'remove1', keep: 'keep1' }, level2b: { target: 'remove2', keep: 'keep2' }, }, } const result = exclude(obj, ['level1.*.target']) expect(result).toEqual({ level1: { level2a: { keep: 'keep1' }, level2b: { keep: 'keep2' } }, }) }) test('should not modify original object by default', () => { const obj = { a: 1, b: 2, c: 3 } const result = exclude(obj, ['b']) expect(obj).toEqual({ a: 1, b: 2, c: 3 }) expect(result).toEqual({ a: 1, c: 3 }) }) test('should modify in place when modifyInPlace is true', () => { const obj = { a: 1, b: 2, c: 3 } const result = exclude(obj, ['b'], { modifyInPlace: true }) expect(obj).toEqual({ a: 1, c: 3 }) expect(result).toBe(obj) }) test('should handle empty paths array', () => { const obj = { a: 1, b: 2 } const result = exclude(obj, []) expect(result).toEqual({ a: 1, b: 2 }) }) test('should handle non-matching patterns', () => { const obj = { a: 1, b: 2 } const result = exclude(obj, ['c', 'd.e']) expect(result).toEqual({ a: 1, b: 2 }) }) test('should handle multiple star patterns', () => { const obj = { users: [ { posts: [{ title: 'Post 1', secret: 'hidden1' }] }, { posts: [{ title: 'Post 2', secret: 'hidden2' }] }, ], } const result = exclude(obj, ['users.*.posts.*.secret']) expect(result).toEqual({ users: [ { posts: [{ title: 'Post 1' }] }, { posts: [{ title: 'Post 2' }] }, ], }) }) test('should support prefix matching', () => { const obj = { documents: { fileName: 'test.txt', size: 100, metadata: { author: 'John', created: '2024-01-01' }, }, users: [ { name: 'Alice', email: 'alice@example.com' }, { name: 'Bob', email: 'bob@example.com' }, ], } const result = exclude(obj, ['documents']) expect(result).toEqual({ users: [ { name: 'Alice', email: 'alice@example.com' }, { name: 'Bob', email: 'bob@example.com' }, ], }) }) test('should support prefix matching with nested paths', () => { const obj = { documents: { fileName: 'test.txt', size: 100, metadata: { author: 'John', created: '2024-01-01' }, }, settings: { theme: 'dark' }, } const result = exclude(obj, ['documents.metadata']) expect(result).toEqual({ documents: { fileName: 'test.txt', size: 100 }, settings: { theme: 'dark' }, }) }) test('should support prefix matching with arrays', () => { const obj = { documents: [ { fileName: 'doc1.txt', size: 100, metadata: { author: 'Alice' } }, { fileName: 'doc2.txt', size: 200, metadata: { author: 'Bob' } }, ], settings: { theme: 'dark' }, } const result = exclude(obj, ['documents.*.metadata']) expect(result).toEqual({ documents: [ { fileName: 'doc1.txt', size: 100 }, { fileName: 'doc2.txt', size: 200 }, ], settings: { theme: 'dark' }, }) }) })