From 2231c0e8f782d6dfcdbf20509340eb4aad095d78 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Thu, 11 Jul 2024 20:39:56 -0400 Subject: [PATCH] feat: add `traverse` function (#59) --- benchmarks/object/traverse.bench.ts | 11 + docs/object/traverse.mdx | 244 +++++++++++++++++ src/mod.ts | 2 + src/object/traverse.ts | 263 ++++++++++++++++++ src/typed/isIterable.ts | 3 + tests/object/traverse.test.ts | 399 ++++++++++++++++++++++++++++ 6 files changed, 922 insertions(+) create mode 100644 benchmarks/object/traverse.bench.ts create mode 100644 docs/object/traverse.mdx create mode 100644 src/object/traverse.ts create mode 100644 src/typed/isIterable.ts create mode 100644 tests/object/traverse.test.ts diff --git a/benchmarks/object/traverse.bench.ts b/benchmarks/object/traverse.bench.ts new file mode 100644 index 00000000..32a99122 --- /dev/null +++ b/benchmarks/object/traverse.bench.ts @@ -0,0 +1,11 @@ +import * as _ from 'radashi' +import { bench } from 'vitest' + +describe('traverse', () => { + const root = { + a: { b: { c: { d: { e: 1 } } } }, + } + bench('basic traversal', () => { + _.traverse(root, () => {}) + }) +}) diff --git a/docs/object/traverse.mdx b/docs/object/traverse.mdx new file mode 100644 index 00000000..e1206825 --- /dev/null +++ b/docs/object/traverse.mdx @@ -0,0 +1,244 @@ +--- +title: traverse +description: Deeply enumerate an object and any nested objects +--- + +### Usage + +Recursively visit each property of an object (or each element of an array) and its nested objects or arrays. To traverse non-array iterables (e.g. `Map`, `Set`) and class instances, see the [Traversing other objects](#traversing-other-objects) section. + +Traversal is performed in a depth-first manner. That means the deepest object will be visited before the last property of the root object. + +```ts +import * as _ from 'radashi' + +const root = { a: { b: 2 }, c: [1, 2] } + +_.traverse(root, (value, key, parent, context) => { + const depth = context.parents.length + console.log(' '.repeat(depth * 2), key, '=>', value) +}) +// Logs the following: +// a => { b: 2 } +// b => 2 +// c => [1, 2] +// 0 => 1 +// 1 => 2 +``` + +**Tip:** Check out the [Advanced](#advanced) section to see what else is possible. + +:::tip[Did you know?] + +- Sparse arrays don't have their holes visited. +- Circular references are skipped. + +::: + +## Types + +### TraverseVisitor + +The `TraverseVisitor` type represents the function passed to `traverse` as its 2nd argument. If you ever need to declare a visitor separate from a `traverse` call, you can do so by declaring a function with this type signature. + +```ts +import { TraverseVisitor } from 'radashi' + +const visitor: TraverseVisitor = (value, key, parent, context) => { + // ... +} +``` + +### TraverseContext + +Every visit includes a context object typed with `TraverseContext`, which contains the following properties: + +- `key`: The current key being visited. +- `parent`: The parent object of the current value. +- `parents`: An array of objects (from parent to child) that the current value is contained by. +- `path`: An array describing the key path to the current value from the root. +- `skip`: A function used for skipping traversal of an object. If no object is provided, the current value is skipped. See [Skipping objects](#skipping-objects) for more details. +- `skipped`: A set of objects that have been skipped. +- `value`: The current value being visited. + +:::danger + +The `path` and `parents` arrays are mutated by the `traverse` function. If you need to use them after the current visit, you should make a copy. + +::: + +### TraverseOptions + +You may set these options for `traverse` using an object as its 3rd argument. + +- `ownKeys`: A function that returns the own enumerable property names of an object. +- `rootNeedsVisit`: A boolean indicating whether the root object should be visited. + +See the [Options](#options) section for more details. + +## Options + +### Traversing all properties + +By default, non-enumerable properties and symbol properties are skipped. You can pass in a custom `ownKeys` implementation to control which object properties are visited. + +This example shows how `Reflect.ownKeys` can be used to include non-enumerable properties and symbol properties. Note that symbol properties are always traversed last when using `Reflect.ownKeys`. + +```ts +import * as _ from 'radashi' + +const symbol = Symbol('b') +const root = { [symbol]: 1 } +Object.defineProperty(root, 'a', { value: 2, enumerable: false }) + +_.traverse( + root, + (value, key) => { + console.log(key, '=>', value) + }, + { ownKeys: Reflect.ownKeys }, +) +// Logs the following: +// a => 2 +// Symbol(b) => 1 +``` + +### Visiting the root object + +By default, your `visitor` callback will never receive the object passed into `traverse`. To override this behavior, set the `rootNeedsVisit` option to true. + +When the root object is visited, the `key` will be `null`. + +```ts +import * as _ from 'radashi' + +const root = { a: 1 } + +_.traverse( + root, + (value, key) => { + console.log(key, '=>', value) + }, + { rootNeedsVisit: true }, +) +// Logs the following: +// null => { a: 1 } +// a => 1 +``` + +## Advanced + +### Traversing other objects + +If traversing plain objects and arrays isn't enough, try calling `traverse` from within another `traverse` callback like follows. This takes advantage of the fact that the root object is always traversed. + +```ts +import * as _ from 'radashi' + +// Note how we're using a named visitor function so it can reference itself. +_.traverse(root, function visitor(value, key, parent, context, options) { + if (value instanceof MyClass) { + return _.traverse(value, visitor, options, context) + } + // TODO: Handle other values as needed. +}) +``` + +If you didn't set any options, the `options` argument can be null: + +```ts +return _.traverse(root, visitor, null, context) +``` + +### Skipping objects + +Using the `TraverseContext::skip` method, you can prevent an object from being traversed. By calling `skip()` with no arguments, the current value won't be traversed. + +```ts +import * as _ from 'radashi' + +const root = { + a: { b: 1 }, + c: { d: 2 }, +} + +_.traverse(root, (value, key, parent, context) => { + console.log(key, '=>', value) + + // Skip traversal of the 'a' object. + if (key === 'a') { + context.skip() + } +}) +// Logs the following: +// a => { b: 1 } +// c => { d: 2 } +// d => 2 +``` + +You can pass any object to `skip()` to skip traversal of that object. + +```ts +import * as _ from 'radashi' + +const root = { + a: { + b: { + c: 1, + }, + }, +} + +_.traverse(root, (value, key, parent, context) => { + console.log(key, '=>', value) + + // Visit the properties of the current object, but skip any objects nested within. + Object.values(value).forEach(nestedValue => { + if (_.isObject(nestedValue)) { + context.skip(nestedValue) + } + }) +}) +// Logs the following: +// a => { b: { c: 1 } } +// b => { c: 1 } +``` + +### Exiting early + +If your `visitor` callback returns false, `traverse` will exit early and also return false. This is useful if you found what you wanted, so you don't need to traverse the rest of the objects. + +```ts +let found = null +_.traverse(root, value => { + if (isWhatImLookingFor(value)) { + found = value + return false + } +}) +``` + +### Leave callbacks + +If your `visitor` callback returns a function, it will be called once `traverse` has visited every visitable property/element within the current object. This is known as a “leave callback”. + +Your leave callback can return false to exit traversal early. + +```ts +_.traverse({ arr: ['a', 'b'] }, (value, key) => { + if (isArray(value)) { + console.log('start of array') + return () => { + console.log('end of array') + return false + } + } else { + console.log(key, '=>', value) + } +}) +// Logs the following: +// start of array +// 0 => 'a' +// 1 => 'b' +// end of array +``` diff --git a/src/mod.ts b/src/mod.ts index 25238f89..bbf41042 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -77,6 +77,7 @@ export * from './object/omit.ts' export * from './object/pick.ts' export * from './object/set.ts' export * from './object/shake.ts' +export * from './object/traverse.ts' export * from './object/upperize.ts' export * from './random/draw.ts' @@ -103,6 +104,7 @@ export * from './typed/isFloat.ts' export * from './typed/isFunction.ts' export * from './typed/isInt.ts' export * from './typed/isIntString.ts' +export * from './typed/isIterable.ts' export * from './typed/isMap.ts' export * from './typed/isNumber.ts' export * from './typed/isObject.ts' diff --git a/src/object/traverse.ts b/src/object/traverse.ts new file mode 100644 index 00000000..98482055 --- /dev/null +++ b/src/object/traverse.ts @@ -0,0 +1,263 @@ +import { isArray, isFunction, isIterable, isPlainObject, last } from 'radashi' + +export interface TraverseOptions { + /** + * A function that returns the keys of the object to be traversed. + * + * @default Object.keys + */ + ownKeys?: ((parent: object) => Iterable) | null + /** + * When true, the visitor callback will be invoked for the root object. + * + * @default false + */ + rootNeedsVisit?: boolean | null +} + +/** + * Recursively visit each property of an object (or each element of an + * array) and its nested objects or arrays. By default, the only + * nested objects to be traversed are plain objects and arrays. + * + * @see https://radashi-org.github.io/reference/object/traverse + * @example + * ```ts + * import { traverse } from 'radashi' + * + * const root = { a: 1, b: { c: { d: [2] }, e: 3 } } + * + * traverse(root, (value, key, parent, context) => { + * console.log(key, '=>', value) + * }) + * // Logs the following: + * // a => 1 + * // b => { … } + * // c => { … } + * // d => [ 2 ] + * // 0 => 2 + * // e => 3 + * ``` + */ +export function traverse( + root: object, + visitor: TraverseVisitor, + options?: (TraverseOptions & { rootNeedsVisit?: null }) | null, + outerContext?: TraverseContext, +): boolean + +export function traverse( + root: object, + visitor: TraverseVisitor, + options?: TraverseOptions | null, + outerContext?: TraverseContext, +): boolean + +export function traverse( + root: object, + visitor: TraverseVisitor, + options?: TraverseOptions | null, + outerContext?: TraverseContext | null, +): boolean { + const context = (outerContext ?? { + value: null, + key: null, + parent: null, + parents: [], + path: [], + skipped: new Set(), + skip(obj) { + context.skipped.add(obj ?? context.value) + }, + }) as TraverseContext & { + value: unknown + key: keyof any | null + parent: any + parents: object[] + path: (keyof any)[] + skipped: Set + } + + const { rootNeedsVisit } = (options ??= {}) + const ownKeys = options.ownKeys ?? Object.keys + const nestedOptions = { + ...options, + rootNeedsVisit: null, + } + + let ok = true + + // This is called for each value in an object or iterable. + const visit = (value: unknown, key: keyof any): boolean => { + // Map entries are destructured into value and key. + if (context.parent.constructor === Map) { + ;[key, value] = value as [keyof any, unknown] + } + + context.path.push(key) + const result = visitor( + (context.value = value), + (context.key = key), + context.parent, + context, + nestedOptions, + ) + + if (result === false) { + return (ok = false) + } + + // Traverse nested plain objects and arrays that haven't been + // skipped and aren't a circular reference. + if ( + value !== null && + typeof value === 'object' && + (isArray(value) || isPlainObject(value)) && + !context.skipped.has(value) && + !context.parents.includes(value) + ) { + traverse(value, result) + } + + context.path.pop() + return ok + } + + const traverse = ( + parent: object, + parentResult?: ReturnType, + ): boolean => { + context.parents.push(parent) + context.parent = parent + + if (rootNeedsVisit && parent === root) { + parentResult = visitor( + (context.value = parent), + (context.key = null), + context.parent, + context, + nestedOptions, + ) + if (parentResult === false) { + return ok + } + } + + if (isArray(parent)) { + // Use Array.prototype.forEach for arrays so that sparse arrays + // are efficiently traversed. The array must be cloned so it can + // be cleared if the visitor returns false. + parent.slice().forEach((value, index, values) => { + if (visit(value, index) === false) { + values.length = 0 // Stop further traversal. + } + }) + } + // Allow an iterable (e.g. a Map or a Set) to be passed in as the + // root object. + else if (parent === root && isIterable(parent)) { + let index = 0 + for (const value of parent) { + if (visit(value, index) === false) { + return ok + } + index++ + } + } + // Traverse the object's properties. + else { + for (const key of ownKeys(parent)) { + if (visit(parent[key as keyof object], key) === false) { + return ok + } + } + } + + context.parents.pop() + context.parent = last(context.parents) + + // Invoke the leave callback for the completed parent. + if (ok && isFunction(parentResult)) { + ok = parentResult() !== false + } + + return ok + } + + if (outerContext) { + // If this is a recursive call, it's possible that the root object + // was skipped earlier. Check for that here so the caller doesn't + // have to check for it. + if (outerContext.skipped.has(root)) { + return true + } + + // We'll have to restore the context after the traversal because + // it might be used by the caller. + const { value, key } = context + + traverse(root) + + context.value = value + context.key = key + return ok + } + + return traverse(root) +} + +/** + * The visitor callback for the `traverse` function. + */ +export type TraverseVisitor = ( + value: unknown, + key: Key, + parent: object, + context: TraverseContext, + options: TraverseOptions & { rootNeedsVisit?: null }, +) => (() => boolean | void) | boolean | void + +/** + * The context object for the `traverse` function. + */ +export interface TraverseContext { + /** + * The current value being traversed. + */ + readonly value: unknown + /** + * The property key or index with which the current value was found. + */ + readonly key: Key + /** + * The parent object of the current value. + */ + readonly parent: object + /** + * The stack of parent objects. The last object is the current + * parent. + * + * ⚠️ This array must not be mutated directly or referenced outside + * the `visitor` callback. If that's necessary, you'll want to clone + * it first. + */ + readonly parents: readonly object[] + /** + * The path to the `parent` object. The last key is the current key. + * + * ⚠️ This array must not be mutated directly or referenced outside + * the `visitor` callback. If that's necessary, you'll want to clone + * it first. + */ + readonly path: readonly (keyof any)[] + /** + * When the current value is a traversable object/iterable, this + * function can be used to skip traversing it altogether. + * + * If the `obj` argument is provided, it marks the given object as + * skipped (instead of the current value), preventing it from being + * traversed. + */ + readonly skip: (obj?: object) => void + readonly skipped: ReadonlySet +} diff --git a/src/typed/isIterable.ts b/src/typed/isIterable.ts new file mode 100644 index 00000000..7e666a9c --- /dev/null +++ b/src/typed/isIterable.ts @@ -0,0 +1,3 @@ +export function isIterable(value: unknown): value is Iterable { + return typeof value === 'object' && value !== null && Symbol.iterator in value +} diff --git a/tests/object/traverse.test.ts b/tests/object/traverse.test.ts new file mode 100644 index 00000000..587eaeeb --- /dev/null +++ b/tests/object/traverse.test.ts @@ -0,0 +1,399 @@ +import * as _ from 'radashi' + +describe('traverse', () => { + test('traverse all properties of a plain object', () => { + const obj = { a: 1, b: { c: 2 } } + + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key) => { + visited.push([key, value]) + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', { c: 2 }], + ['c', 2], + ]) + }) + + test('traverse all elements of an array', () => { + const arr = [1, [2, 3]] + + const visited: [keyof any, unknown][] = [] + _.traverse(arr, (value, key) => { + visited.push([key, value]) + }) + + expect(visited).toEqual([ + [0, 1], + [1, [2, 3]], + [0, 2], + [1, 3], + ]) + }) + + test('stop traversal when visitor returns false', () => { + const obj = { a: 1, b: { c: 2 } } + + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key) => { + visited.push([key, value]) + if (key === 'b') { + return false + } + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', { c: 2 }], + ]) + }) + + test('skip traversal of an object when context.skip is called', () => { + const obj = { a: 1, b: { c: 2 } } + + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key, parent, context) => { + visited.push([key, value]) + if (key === 'b') { + context.skip() + } + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', { c: 2 }], + ]) + }) + + test('traverse non-enumerable properties when ownKeys is customized', () => { + const obj: Record = { a: 1 } + Object.defineProperty(obj, 'b', { value: 2, enumerable: false }) + const symbolKey = Symbol('key') + obj[symbolKey] = 3 + + const visited: [keyof any, unknown][] = [] + _.traverse( + obj, + (value, key) => { + visited.push([key, value]) + }, + { ownKeys: Reflect.ownKeys }, + ) + + expect(visited).toEqual([ + ['a', 1], + ['b', 2], + [symbolKey, 3], + ]) + }) + + test('traverse a root iterable (e.g., Map)', () => { + const map = new Map([ + ['a', 1], + ['b', { c: 2 }], + ]) + + const visited: [keyof any, unknown][] = [] + _.traverse(map, (value, key) => { + visited.push([key, value]) + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', { c: 2 }], + ['c', 2], + ]) + }) + + test('traverse nested objects and arrays combined', () => { + const obj = { a: [1, { b: 2 }], c: 3 } + + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key) => { + visited.push([key, value]) + }) + + expect(visited).toEqual([ + ['a', [1, { b: 2 }]], + [0, 1], + [1, { b: 2 }], + ['b', 2], + ['c', 3], + ]) + }) + + test('traverse a sparse array', () => { + // biome-ignore lint/suspicious/noSparseArray: + const arr = [1, , 3] + arr[5] = 5 + + const visited: [keyof any, unknown][] = [] + _.traverse(arr, (value, key) => { + visited.push([key, value]) + }) + + expect(visited).toEqual([ + [0, 1], + [2, 3], + [5, 5], + ]) + }) + + test('traverse a root Set', () => { + const set = new Set([1, { a: 2 }]) + + const visited: [keyof any, unknown][] = [] + _.traverse(set, (value, key) => { + visited.push([key, value]) + }) + + expect(visited).toEqual([ + [0, 1], + [1, { a: 2 }], + ['a', 2], + ]) + }) + + test('skip traversal of specific nested objects', () => { + const obj = { a: 1, b: { c: 2, d: { e: 3 } } } + + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key, _parent, context) => { + visited.push([key, value]) + if (key === 'd') { + context.skip() + } + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', { c: 2, d: { e: 3 } }], + ['c', 2], + ['d', { e: 3 }], + ]) + }) + + test('return early from traversal due to a specific condition', () => { + const obj = { a: 1, b: 2, c: 3 } + + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key) => { + visited.push([key, value]) + if (value === 2) { + return false + } + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', 2], + ]) + }) + + test('return early while traversing a nested array', () => { + const arr = [1, [2, 3, [4]]] + + const visited: [keyof any, unknown][] = [] + _.traverse(arr, (value, key) => { + visited.push([key, value]) + if (value === 2) { + return false + } + }) + + expect(visited).toEqual([ + [0, 1], + [1, [2, 3, [4]]], + [0, 2], + ]) + }) + + test('return early while traversing a non-array iterable', () => { + const map = new Map([ + ['a', 1], + ['b', 2], + ['c', 3], + ]) + + const visited: [keyof any, unknown][] = [] + _.traverse(map, (value, key) => { + visited.push([key, value]) + if (value === 2) { + return false + } + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', 2], + ]) + }) + + test('handle objects with circular references', () => { + const obj: Record = { a: 1 } + obj.b = obj + + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key) => { + visited.push([key, value]) + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', obj], + ]) + }) + + test('handle non-plain objects (e.g., class instances)', () => { + class MyClass { + constructor( + public a: unknown, + public b: unknown, + ) {} + } + + const instance = new MyClass(1, { c: 2 }) + + const visited: [keyof any, unknown][] = [] + _.traverse(instance, (value, key) => { + visited.push([key, value]) + }) + + expect(visited).toEqual([ + ['a', 1], + ['b', { c: 2 }], + ['c', 2], + ]) + }) + + test('call traverse from within a visitor', () => { + const nestedMap = new Map([['c', 2]]) + const map = new Map([ + ['a', 1], + ['b', nestedMap], + ]) + + const visited: [(keyof any)[], unknown][] = [] + _.traverse(map, function visitor(value, _key, _parent, context, options) { + visited.push([[...context.path], value]) + if (value instanceof Map) { + _.traverse(value, visitor, options, context) + } + }) + + expect(visited).toEqual([ + [['a'], 1], + [['b'], nestedMap], + [['b', 'c'], 2], + ]) + }) + + test('ensure path and parents properties are correctly maintained', () => { + const obj = { a: { b: 2 } } + const paths: (keyof any)[][] = [] + const parents: unknown[][] = [] + _.traverse(obj, (_value, _key, _parent, ctx) => { + paths.push([...ctx.path]) + parents.push([...ctx.parents]) + }) + expect(paths).toEqual([['a'], ['a', 'b']]) + expect(parents).toEqual([[obj], [obj, obj.a]]) + }) + + test('visitor can return a leave callback', () => { + const obj = { a: { b: 2 } } + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key) => { + visited.push([key, value]) + return () => { + visited.push([key, value]) + } + }) + expect(visited).toEqual([ + ['a', { b: 2 }], + ['b', 2], + ['a', { b: 2 }], + ]) + }) + + test('leave callback that returns false', () => { + const obj = { a: { b: { type: 'b' }, c: { type: 'c' } } } + const visited: [keyof any, unknown][] = [] + _.traverse(obj, (value, key) => { + visited.push([key, value]) + return () => { + visited.push([key, value]) + // Stop traversing once an object is fully traversed. + return false + } + }) + expect(visited).toEqual([ + ['a', obj.a], + ['b', obj.a.b], + ['type', 'b'], + ['b', obj.a.b], + ]) + }) + + test('rootNeedsVisit=true with early exit', () => { + const obj = { a: { b: 1 } } + const visited: [keyof any | null, unknown][] = [] + _.traverse( + obj, + (value, key) => { + visited.push([key, value]) + return false + }, + { rootNeedsVisit: true }, + ) + expect(visited).toEqual([[null, obj]]) + }) + + test('rootNeedsVisit=true with leave callback', () => { + const obj = { a: 1 } + const visited: [keyof any | null, unknown][] = [] + _.traverse( + obj, + (value, key) => { + visited.push([key, value]) + return () => { + visited.push([key, value]) + } + }, + { rootNeedsVisit: true }, + ) + expect(visited).toEqual([ + [null, obj], + ['a', obj.a], + [null, obj], + ]) + }) + + test('nested traverse with skipped root', () => { + class Random { + value = Math.random() + } + const obj = { r: new Random() } + const visited: [keyof any | null, unknown][] = [] + _.traverse( + obj, + function visitor(value, key, _parent, context) { + visited.push([key, value]) + if (key === null) { + context.skip(obj.r) + } else if (value instanceof Random) { + // This is a no-op since `obj.r` was skipped. + _.traverse(value, visitor, null, context) + } + }, + { rootNeedsVisit: true }, + ) + // The `Random::value` instance property shouldn't be visited + // since `context.skip` was called with the Random instance. + expect(visited).toEqual([ + [null, obj], + ['r', obj.r], + ]) + }) +})