Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Exclude manual test files from published package
test/manual/
# Debug Logs
npm-debug.log

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "deeks",
"version": "3.1.2",
"version": "3.2.0",
"description": "Retrieve all keys and nested keys from objects and arrays of objects.",
"main": "lib/deeks.js",
"types": "lib/deeks.d.ts",
Expand All @@ -10,7 +10,7 @@
"coverage": "nyc npm run test",
"lint": "eslint --ext .js,.ts src test",
"prepublishOnly": "npm run build",
"test": "mocha -r ts-node/register test/index.ts"
"test": "mocha -r ts-node/register test/index.ts test/deepCircular.spec.ts"
},
"repository": {
"type": "git",
Expand Down
100 changes: 78 additions & 22 deletions src/deeks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ export * from './types';
*/
export function deepKeys(object: object, options?: DeeksOptions): string[] {
const parsedOptions = mergeOptions(options);
const visited = new WeakSet<object>();

if (typeof object === 'object' && object !== null) {
return generateDeepKeysList('', object as Record<string, unknown>, parsedOptions);
return generateDeepKeysList('', object as Record<string, unknown>, parsedOptions, visited);
}
return [];
}
Expand All @@ -25,36 +27,90 @@ export function deepKeys(object: object, options?: DeeksOptions): string[] {
* @param options
* @returns Array[Array[String]]
*/
export function deepKeysFromList(list: object[], options?: DeeksOptions): string[][] {
export function deepKeysFromList(list: object[], options?: DeeksOptions, visited?: WeakSet<object>): string[][] {
const parsedOptions = mergeOptions(options);
const localVisited = visited ?? new WeakSet<object>();

return list.map((document: object): string[] => { // for each document
if (typeof document === 'object' && document !== null) {
// avoid re-traversing objects we've already seen (circular refs)
if (localVisited.has(document)) {
return [];
}

// if the data at the key is a document, then we retrieve the subHeading starting with an empty string heading and the doc
return deepKeys(document, parsedOptions);
return generateDeepKeysList('', document as Record<string, unknown>, parsedOptions, localVisited);
}
return [];
});
}

function generateDeepKeysList(heading: string, data: Record<string, unknown>, options: DeeksOptions): string[] {
const keys = Object.keys(data).map((currentKey: string) => {
// If the given heading is empty, then we set the heading to be the subKey, otherwise set it as a nested heading w/ a dot
const keyName = buildKeyName(heading, escapeNestedDotsIfSpecified(currentKey, options));

// If we have another nested document, recur on the sub-document to retrieve the full key name
if (options.expandNestedObjects && utils.isDocumentToRecurOn(data[currentKey]) || (options.arrayIndexesAsKeys && Array.isArray(data[currentKey]) && (data[currentKey] as unknown[]).length)) {
return generateDeepKeysList(keyName, data[currentKey] as Record<string, unknown>, options);
} else if (options.expandArrayObjects && Array.isArray(data[currentKey])) {
// If we have a nested array that we need to recur on
return processArrayKeys(data[currentKey] as object[], keyName, options);
} else if (options.ignoreEmptyArrays && Array.isArray(data[currentKey]) && !(data[currentKey] as unknown[]).length) {
return [];
function generateDeepKeysList(heading: string, data: Record<string, unknown>, options: DeeksOptions, visited: WeakSet<object>): string[] {
const result: string[] = [];

// Iterative recursion simulation using an explicit stack of frames so we preserve
// left-to-right processing order (matching the recursive implementation).
interface Frame { obj: Record<string, unknown>; keys: string[]; i: number; basePath: string }
const rootKeys = Object.keys(data);
// mark root as visited to match recursive entry behavior
visited.add(data as unknown as object);
const stack: Frame[] = [{ obj: data, keys: rootKeys, i: 0, basePath: heading }];

while (stack.length) {
const frame = stack[stack.length - 1];

if (frame.i >= frame.keys.length) {
// finished this object
stack.pop();
continue;
}
// Otherwise return this key name since we don't have a sub document
return keyName;
});

return utils.flatten(keys);
const currentKey = frame.keys[frame.i++];
const value = frame.obj[currentKey];
const keyName = buildKeyName(frame.basePath, escapeNestedDotsIfSpecified(currentKey, options));

// If nested document or array-as-object via arrayIndexesAsKeys, descend (push new frame)
if ((options.expandNestedObjects && utils.isDocumentToRecurOn(value)) || (options.arrayIndexesAsKeys && Array.isArray(value) && (value as unknown[]).length)) {
if (options.arrayIndexesAsKeys && Array.isArray(value)) {
// treat array like an object with numeric keys
const arr = value as unknown[];
// push frames in reverse so they are processed in increasing index order
for (let idx = arr.length - 1; idx >= 0; idx--) {
const elem = arr[idx];
const elemPath = buildKeyName(keyName, String(idx));
if (typeof elem === 'object' && elem !== null && !Array.isArray(elem)) {
if (!visited.has(elem as unknown as object)) {
visited.add(elem as unknown as object);
stack.push({ obj: elem as Record<string, unknown>, keys: Object.keys(elem as Record<string, unknown>), i: 0, basePath: elemPath });
}
} else {
// non-object -> behave like leaf (original recursion would have produced the index path)
result.push(elemPath);
}
}
} else {
// value is an object -> push its frame (if not visited)
if (!visited.has(value as unknown as object)) {
visited.add(value as unknown as object);
stack.push({ obj: value as Record<string, unknown>, keys: Object.keys(value as Record<string, unknown>), i: 0, basePath: keyName });
}
}
} else if (options.expandArrayObjects && Array.isArray(value)) {
// call helper and append its results in order
const subKeys = processArrayKeys(value as object[], keyName, options, visited) as string[];
for (const k of subKeys) {
result.push(k as string);
}
} else if (options.ignoreEmptyArrays && Array.isArray(value) && !(value as unknown[]).length) {
// skip
continue;
} else {
// leaf key
result.push(keyName);
}
}

return utils.unique(result) as string[];
}

/**
Expand All @@ -65,8 +121,8 @@ function generateDeepKeysList(heading: string, data: Record<string, unknown>, op
* @param options
* @returns {*}
*/
function processArrayKeys(subArray: object[], currentKeyPath: string, options: DeeksOptions) {
let subArrayKeys = deepKeysFromList(subArray, options);
function processArrayKeys(subArray: object[], currentKeyPath: string, options: DeeksOptions, visited: WeakSet<object>) {
let subArrayKeys = deepKeysFromList(subArray, options, visited);

if (!subArray.length) {
return options.ignoreEmptyArraysWhenExpanding ? [] : [currentKeyPath];
Expand Down
92 changes: 92 additions & 0 deletions test/deepCircular.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use strict';

/* eslint-disable @typescript-eslint/no-explicit-any */

import { deepKeys, deepKeysFromList } from '../src/deeks';
import assert from 'assert';

describe('deep circular stress (automated)', function () {
// allow extra time for building large chains on slower CI
this.timeout(20000);

it('handles a very deep circular chain without stack overflow', () => {
const depth = 5000;
const root: any = { root: true };
let current = root;
for (let i = 0; i < depth; i++) {
const next: any = { ['k' + i]: i };
current.child = next;
current = next;
}

// close the cycle back to root
current.child = root;

const keys = deepKeys(root, { expandNestedObjects: true });

assert.equal(Array.isArray(keys), true);
// expect root + depth entries
assert.equal((keys as string[]).length, depth + 1);
});

// Integrate additional coverage-focused tests here so they run under the
// same "deep circular stress (automated)" section.
it('deepKeysFromList returns [] for repeated documents (visited guard)', () => {
const obj: any = { a: 1 };
const list = [obj, obj];

const keys = deepKeysFromList(list as object[]);

assert.equal(Array.isArray(keys), true);
assert.deepEqual(keys, [['a'], []]);
});

it('arrayIndexesAsKeys includes numeric index paths for non-object entries', () => {
const testObj = {
list: [{ a: 1 }, 5]
};

const keys = deepKeys(testObj, { arrayIndexesAsKeys: true });

assert.equal(Array.isArray(keys), true);
// expect the object entry to yield 'list.0.a' and the non-object entry to yield 'list.1'
assert.deepEqual(keys.sort(), ['list.0.a', 'list.1'].sort());
});

it('arrayIndexesAsKeys works for primitive-only arrays (index path)', () => {
const testObj = { arr: [1] };
const keys = deepKeys(testObj, { arrayIndexesAsKeys: true });

assert.equal(Array.isArray(keys), true);
assert.deepEqual(keys, ['arr.0']);
});

it('arrayIndexesAsKeys works for nested primitive arrays', () => {
const testObj = { outer: { arr: [1] } };
const keys = deepKeys(testObj, { arrayIndexesAsKeys: true });

assert.equal(Array.isArray(keys), true);
assert.deepEqual(keys, ['outer.arr.0']);
});

it('arrayIndexesAsKeys does not re-traverse the same object repeated in an array', () => {
const shared = { a: 1 };
const testObj = { arr: [shared, shared] };
const keys = deepKeys(testObj, { arrayIndexesAsKeys: true });

// Only one set of keys should be produced for the shared object
assert.equal(Array.isArray(keys), true);
assert.deepEqual(keys.sort(), ['arr.1.a'].sort());
});

it('does not re-traverse the same nested object (visited prevents duplicate traversal)', () => {
const shared: any = { x: 1 };
const testObj: any = { a: shared, b: shared };

const keys = deepKeys(testObj);

assert.equal(Array.isArray(keys), true);
// 'a.x' should be present but 'b.x' should not because the object was already visited
assert.deepEqual(keys, ['a.x']);
});
});
58 changes: 58 additions & 0 deletions test/manual/deepCircularStress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use strict';

/**
* MANUAL TEST: deepCircularStress
*
* This is a manual stress test that constructs a very deep circular
* object chain and runs `deepKeys` against it to exercise the
* iterative traversal and confirm it doesn't hit the JS call stack
* limit.
*
* IMPORTANT:
* - This file is intended to be run manually (it can be slow on CI).
* - It is placed under `test/manual/` and excluded from published
* packages via `.npmignore` so it won't be run automatically.
*
* How to run locally:
* # run with the default depth (5000)
* npx ts-node test/manual/deepCircularStress.ts
*
* # or override depth using DEPTH env var
* DEPTH=10000 npx ts-node test/manual/deepCircularStress.ts
*
* Exit codes:
* - 0: success
* - 2: deepKeys threw an exception (e.g. RangeError)
*/

/* eslint-disable @typescript-eslint/no-explicit-any */

import { deepKeys } from '../../src/deeks';

const depth = Number(process.env.DEPTH) || 5000;
console.log(`Building circular chain with depth=${depth}`);

const root: any = { root: true };
let current = root;
for (let i = 0; i < depth; i++) {
const next: any = { ['k' + i]: i };
current.child = next;
current = next;
}

// close the cycle back to root
current.child = root;

try {
console.time('deepKeys');
const keys = deepKeys(root, { expandNestedObjects: true });
console.timeEnd('deepKeys');
console.log('returned keys count:', Array.isArray(keys) ? keys.length : 'not-array');
if (Array.isArray(keys)) {
console.log('sample keys:', keys.slice(0, 10));
}
process.exit(0);
} catch (err: any) {
console.error('deepKeys threw:', err && err.stack ? err.stack.split('\n')[0] : err);
process.exit(2);
}