diff --git a/.gitignore b/.gitignore index d6aae37..cacbd92 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ node_modules/ ########### GENERAL ############# *.DS_Store .python-version +*.tgz diff --git a/dist/js/shared/object.d.ts b/dist/js/shared/object.d.ts index abf1e56..fefe1e6 100644 --- a/dist/js/shared/object.d.ts +++ b/dist/js/shared/object.d.ts @@ -94,4 +94,29 @@ export declare function mergeTerminalNodes(tree: Tree, unique?: b * Useful for extracting entities from deeply nested configurations. */ export declare function flattenNestedObjects(nestedData: Record>, filterFunction?: (item: T) => boolean): Record; +/** + * Recursively walks a plain object (or array) and applies a mapping function + * to every nested plain-object node. + * + * Non-plain objects (class instances, Date, RegExp, etc.) and primitives are + * returned unchanged. + * + * @param object - The value to traverse. + * @param mapValue - A function called on every plain-object node. If it returns + * a truthy value, that value replaces the node (and its children are still + * traversed). If it returns a falsy value the original node is kept. + * @returns A new object tree with the mapping applied. + * + * @example + * ```ts + * const result = mapObjectDeep( + * { a: { val: "$ref.x" }, b: [{ val: "$ref.y" }] }, + * (node) => { + * if (node.val?.startsWith("$ref.")) return { ...node, val: "resolved" }; + * }, + * ); + * // { a: { val: "resolved" }, b: [{ val: "resolved" }] } + * ``` + */ +export declare function mapObjectDeep(object: any, mapValue: (node: any) => any | undefined): any; export {}; diff --git a/dist/js/shared/object.js b/dist/js/shared/object.js index 83f883f..f2efb65 100644 --- a/dist/js/shared/object.js +++ b/dist/js/shared/object.js @@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.flattenNestedObjects = exports.mergeTerminalNodes = exports.sortKeysDeepForObjectWithExclude = exports.sortKeysDeepForObject = exports.flattenObject = exports.stringifyObject = exports.renameKeysForObject = exports.convertKeysToCamelCaseForObject = exports.getOneMatchFromObject = exports.safeMakeObject = void 0; +exports.mapObjectDeep = exports.flattenNestedObjects = exports.mergeTerminalNodes = exports.sortKeysDeepForObjectWithExclude = exports.sortKeysDeepForObject = exports.flattenObject = exports.stringifyObject = exports.renameKeysForObject = exports.convertKeysToCamelCaseForObject = exports.getOneMatchFromObject = exports.safeMakeObject = void 0; const camelCase_1 = __importDefault(require("lodash/camelCase")); const filter_1 = __importDefault(require("lodash/filter")); const isArray_1 = __importDefault(require("lodash/isArray")); @@ -253,3 +253,44 @@ function flattenNestedObjects(nestedData, filterFunction) { return flattened; } exports.flattenNestedObjects = flattenNestedObjects; +/** + * Recursively walks a plain object (or array) and applies a mapping function + * to every nested plain-object node. + * + * Non-plain objects (class instances, Date, RegExp, etc.) and primitives are + * returned unchanged. + * + * @param object - The value to traverse. + * @param mapValue - A function called on every plain-object node. If it returns + * a truthy value, that value replaces the node (and its children are still + * traversed). If it returns a falsy value the original node is kept. + * @returns A new object tree with the mapping applied. + * + * @example + * ```ts + * const result = mapObjectDeep( + * { a: { val: "$ref.x" }, b: [{ val: "$ref.y" }] }, + * (node) => { + * if (node.val?.startsWith("$ref.")) return { ...node, val: "resolved" }; + * }, + * ); + * // { a: { val: "resolved" }, b: [{ val: "resolved" }] } + * ``` + */ +function mapObjectDeep(object, mapValue) { + if (typeof object !== "object" || object === null) { + return object; + } + if (Array.isArray(object)) { + return object.map((innerValue) => mapObjectDeep(innerValue, mapValue)); + } + if (object.constructor !== Object) { + return object; + } + const mappedObject = mapValue(object) || object; + const entries = Object.entries(mappedObject).map(([key, value]) => { + return [key, mapObjectDeep(value, mapValue)]; + }); + return Object.fromEntries(entries); +} +exports.mapObjectDeep = mapObjectDeep; diff --git a/src/js/shared/object.ts b/src/js/shared/object.ts index 8283edb..7c8c57e 100644 --- a/src/js/shared/object.ts +++ b/src/js/shared/object.ts @@ -321,3 +321,52 @@ export function flattenNestedObjects( return flattened; } + +/** + * Recursively walks a plain object (or array) and applies a mapping function + * to every nested plain-object node. + * + * Non-plain objects (class instances, Date, RegExp, etc.) and primitives are + * returned unchanged. + * + * @param object - The value to traverse. + * @param mapValue - A function called on every plain-object node. If it returns + * a truthy value, that value replaces the node (and its children are still + * traversed). If it returns a falsy value the original node is kept. + * @returns A new object tree with the mapping applied. + * + * @example + * ```ts + * const result = mapObjectDeep( + * { a: { val: "$ref.x" }, b: [{ val: "$ref.y" }] }, + * (node) => { + * if (node.val?.startsWith("$ref.")) return { ...node, val: "resolved" }; + * }, + * ); + * // { a: { val: "resolved" }, b: [{ val: "resolved" }] } + * ``` + */ +export function mapObjectDeep( + object: any, + mapValue: (node: any) => any | undefined, +): any { + if (typeof object !== "object" || object === null) { + return object; + } + + if (Array.isArray(object)) { + return object.map((innerValue) => mapObjectDeep(innerValue, mapValue)); + } + + if (object.constructor !== Object) { + return object; + } + + const mappedObject = mapValue(object) || object; + + const entries = Object.entries(mappedObject).map(([key, value]) => { + return [key, mapObjectDeep(value, mapValue)]; + }); + + return Object.fromEntries(entries); +} diff --git a/tests/js/object.tests.ts b/tests/js/object.tests.ts index f9de5ed..e7debe9 100644 --- a/tests/js/object.tests.ts +++ b/tests/js/object.tests.ts @@ -3,6 +3,7 @@ import { expect } from "chai"; import { flattenNestedObjects, flattenObject, + mapObjectDeep, mergeTerminalNodes, sortKeysDeepForObject, sortKeysDeepForObjectWithExclude, @@ -178,3 +179,51 @@ describe("sortKeysDeepForObjectWithExclude", () => { expect(result).to.deep.equal(expectedObj); }); }); + +describe("mapObjectDeep", () => { + it("returns primitives and null unchanged", () => { + expect(mapObjectDeep(42, () => undefined)).to.equal(42); + expect(mapObjectDeep("hello", () => undefined)).to.equal("hello"); + expect(mapObjectDeep(null, () => undefined)).to.equal(null); + expect(mapObjectDeep(undefined, () => undefined)).to.equal(undefined); + }); + + it("traverses arrays inside objects and maps nested nodes", () => { + const input = { items: [{ val: 1 }, { val: 2 }] }; + const result = mapObjectDeep(input, (node: any) => { + if (node.val !== undefined) return { val: node.val * 10 }; + }); + expect(result).to.deep.equal({ items: [{ val: 10 }, { val: 20 }] }); + }); + + it("recursively maps nested plain objects", () => { + const input = { a: { b: { val: "original" } } }; + const result = mapObjectDeep(input, (node: any) => { + if (node.val === "original") return { val: "replaced" }; + }); + expect(result).to.deep.equal({ a: { b: { val: "replaced" } } }); + }); + + it("does not recurse into class instances", () => { + const date = new Date("2026-01-01"); + const input = { created: date }; + const result = mapObjectDeep(input, () => undefined); + expect(result.created).to.equal(date); + }); + + it("resolves $ref-style variables (real-world use case)", () => { + const input = { + a: { val: "$ref.x" }, + b: [{ val: "$ref.y" }], + }; + const result = mapObjectDeep(input, (node: any) => { + if (typeof node.val === "string" && node.val.startsWith("$ref.")) { + return { ...node, val: "resolved" }; + } + }); + expect(result).to.deep.equal({ + a: { val: "resolved" }, + b: [{ val: "resolved" }], + }); + }); +});