From 4058dd37f85808a0ab239bcee0a9f5aefc7f4f88 Mon Sep 17 00:00:00 2001 From: Hein Date: Thu, 8 May 2025 16:46:44 +0200 Subject: [PATCH] docs(changeset): Added object retrocycle and decycle --- .changeset/good-beers-sing.md | 5 + pnpm-lock.yaml | 6 +- src/object/decycle.test.ts | 187 ++++++++++++++++++++++++++++++++++ src/object/decycle.ts | 133 ++++++++++++++++++++++++ src/object/index.ts | 4 +- 5 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 .changeset/good-beers-sing.md create mode 100644 src/object/decycle.test.ts create mode 100644 src/object/decycle.ts diff --git a/.changeset/good-beers-sing.md b/.changeset/good-beers-sing.md new file mode 100644 index 0000000..f9ce975 --- /dev/null +++ b/.changeset/good-beers-sing.md @@ -0,0 +1,5 @@ +--- +"@warkypublic/artemis-kit": patch +--- + +Added object retrocycle and decycle diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b5b448..f2cce1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@anthropic-ai/claude-code': - specifier: ^0.2.99 - version: 0.2.99 semver: specifier: ^7.6.3 version: 7.6.3 @@ -18,6 +15,9 @@ importers: specifier: ^11.0.3 version: 11.0.3 devDependencies: + '@anthropic-ai/claude-code': + specifier: ^0.2.99 + version: 0.2.99 '@changesets/cli': specifier: ^2.27.10 version: 2.27.10 diff --git a/src/object/decycle.test.ts b/src/object/decycle.test.ts new file mode 100644 index 0000000..4a3294a --- /dev/null +++ b/src/object/decycle.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest'; +import { decycle, retrocycle } from './decycle'; + +describe('decycle and retrocycle functions', () => { + it('should handle non-circular objects correctly', () => { + const obj = { a: 1, b: 'string', c: true }; + const decycled = decycle(obj); + expect(decycled).toEqual(obj); + + const restored = retrocycle(decycled); + expect(restored).toEqual(obj); + }); + + it('should handle arrays correctly', () => { + const arr = [1, 'string', true, { nested: 'object' }]; + const decycled = decycle(arr); + expect(decycled).toEqual(arr); + + const restored = retrocycle(decycled); + expect(restored).toEqual(arr); + }); + + it('should handle circular references in arrays', () => { + const arr: any[] = [1, 2, 3]; + arr.push(arr); // Create circular reference + + const decycled = decycle(arr); + expect(decycled).toEqual([1, 2, 3, { $ref: '$' }]); + + const restored = retrocycle(decycled); + expect(restored[0]).toBe(1); + expect(restored[1]).toBe(2); + expect(restored[2]).toBe(3); + expect(restored[3]).toBe(restored); // Circular reference restored + }); + + it('should handle circular references in objects', () => { + const obj: any = { a: 1, b: 'string' }; + obj.self = obj; // Create circular reference + + const decycled = decycle(obj); + expect(decycled).toEqual({ a: 1, b: 'string', self: { $ref: '$' } }); + + const restored = retrocycle(decycled); + expect(restored.a).toBe(1); + expect(restored.b).toBe('string'); + expect(restored.self).toBe(restored); // Circular reference restored + }); + + it('should handle nested objects with circular references', () => { + const inner: any = { x: 1 }; + const outer: any = { a: inner, b: 'string' }; + inner.parent = outer; // Create circular reference + + const decycled = decycle(outer); + expect(decycled).toEqual({ + a: { x: 1, parent: { $ref: '$' } }, + b: 'string' + }); + + const restored = retrocycle(decycled); + expect(restored.a.x).toBe(1); + expect(restored.a.parent).toBe(restored); // Circular reference restored + }); + + it('should handle multiple references to the same object', () => { + const shared = { id: 'shared' }; + const obj = { a: shared, b: shared }; + + const decycled = decycle(obj); + expect(decycled).toEqual({ + a: { id: 'shared' }, + b: { $ref: '$["a"]' } + }); + + const restored = retrocycle(decycled); + expect(restored.a).toBe(restored.b); // Both refer to the same object + }); + + it('should work with custom replacer function', () => { + const obj = { a: 1, b: 2, secret: 'hidden' }; + const replacer = (value: any) => { + if (value && typeof value === 'object' && 'secret' in value) { + const copy = { ...value }; + copy.secret = '[REDACTED]'; + return copy; + } + return value; + }; + + const decycled = decycle(obj, replacer); + expect(decycled).toEqual({ a: 1, b: 2, secret: '[REDACTED]' }); + }); + + it('should handle null and undefined values', () => { + const obj = { a: null, b: undefined, c: { d: null } }; + const decycled = decycle(obj); + expect(decycled).toEqual(obj); + + const restored = retrocycle(decycled); + expect(restored).toEqual(obj); + }); + + it('should handle built-in objects like Date, RegExp, and more', () => { + const date = new Date('2023-01-01'); + const regexp = /test/g; + const obj = { + date, + regexp, + bool: new Boolean(true), + num: new Number(42), + str: new String("test") + }; + + const decycled = decycle(obj); + expect(decycled.date).toBeInstanceOf(Date); + expect(decycled.regexp).toBeInstanceOf(RegExp); + expect(decycled.bool).toBeInstanceOf(Boolean); + expect(decycled.num).toBeInstanceOf(Number); + expect(decycled.str).toBeInstanceOf(String); + + const restored = retrocycle(decycled); + expect(restored.date).toBeInstanceOf(Date); + expect(restored.date.toISOString()).toBe(date.toISOString()); + expect(restored.regexp).toBeInstanceOf(RegExp); + expect(restored.regexp.source).toBe(regexp.source); + }); + + it('should handle complex nested circular references', () => { + const a: any = { name: 'a' }; + const b: any = { name: 'b' }; + const c: any = { name: 'c' }; + + a.child = b; + b.child = c; + c.parent = a; // Circular reference + + const decycled = decycle(a); + expect(decycled).toEqual({ + name: 'a', + child: { + name: 'b', + child: { + name: 'c', + parent: { $ref: '$' } + } + } + }); + + const restored = retrocycle(decycled); + expect(restored.name).toBe('a'); + expect(restored.child.name).toBe('b'); + expect(restored.child.child.name).toBe('c'); + expect(restored.child.child.parent).toBe(restored); // Circular reference restored + }); + + it('should handle arrays with multiple circular references', () => { + const arr1: any[] = [1, 2]; + const arr2: any[] = [3, 4]; + arr1.push(arr2); + arr2.push(arr1); // Circular reference + + const decycled = decycle(arr1); + expect(decycled).toEqual([1, 2, [3, 4, { $ref: '$' }]]); + + const restored = retrocycle(decycled); + expect(restored[0]).toBe(1); + expect(restored[1]).toBe(2); + expect(restored[2][0]).toBe(3); + expect(restored[2][1]).toBe(4); + expect(restored[2][2]).toBe(restored); // Circular reference restored + }); + + it('should work with JSON.stringify and JSON.parse for circular structures', () => { + const obj: any = { a: 1, b: 'string' }; + obj.self = obj; // Create circular reference + + const decycled = decycle(obj); + const jsonStr = JSON.stringify(decycled); + const parsed = JSON.parse(jsonStr); + const restored = retrocycle(parsed); + + expect(restored.a).toBe(1); + expect(restored.b).toBe('string'); + expect(restored.self).toBe(restored); // Circular reference restored + }); +}); \ No newline at end of file diff --git a/src/object/decycle.ts b/src/object/decycle.ts new file mode 100644 index 0000000..9edb1f7 --- /dev/null +++ b/src/object/decycle.ts @@ -0,0 +1,133 @@ +/** + * Interface for an object with a $ref property pointing to a path + */ +export interface RefObject { + $ref: string; +} + +/** + * Makes a deep copy of an object or array, replacing circular references + * with objects of the form {"$ref": PATH} where PATH is a JSONPath string + * that locates the first occurrence. + * + * @param object - The object to decycle + * @param replacer - Optional function to replace values during the process + * @returns A decycled copy of the object + */ +export function decycle( + object: T, + replacer?: (value: any) => any +): any { + "use strict"; + + // Object to path mappings + const objects = new WeakMap(); + + return (function derez(value: any, path: string): any { + // The path of an earlier occurrence of value + let old_path: string | undefined; + // The new object or array + let nu: any; + + // If a replacer function was provided, then call it to get a replacement value. + if (replacer !== undefined) { + value = replacer(value); + } + + // typeof null === "object", so go on if this value is really an object but not + // one of the weird builtin objects. + if ( + typeof value === "object" + && value !== null + && !(value instanceof Boolean) + && !(value instanceof Date) + && !(value instanceof Number) + && !(value instanceof RegExp) + && !(value instanceof String) + ) { + // If the value is an object or array, look to see if we have already + // encountered it. If so, return a {"$ref":PATH} object. This uses an + // ES6 WeakMap. + old_path = objects.get(value); + if (old_path !== undefined) { + return {$ref: old_path}; + } + + // Otherwise, accumulate the unique value and its path. + objects.set(value, path); + + // If it is an array, replicate the array. + if (Array.isArray(value)) { + nu = []; + value.forEach(function (element, i) { + nu[i] = derez(element, path + "[" + i + "]"); + }); + } else { + // If it is an object, replicate the object. + nu = {}; + Object.keys(value).forEach(function (name) { + nu[name] = derez( + value[name], + path + "[" + JSON.stringify(name) + "]" + ); + }); + } + return nu; + } + return value; + }(object, "$")); +} + +/** + * Restores an object that was reduced by decycle. Members whose values are + * objects of the form {$ref: PATH} are replaced with references to the value + * found by the PATH. This will restore cycles. + * + * @param $ - The object to restore cycles in + * @returns The object with restored cycles + */ +export function retrocycle($: T): T { + "use strict"; + + // Regular expression to validate JSONPath format + // NOTE: This function uses eval() which can be a security risk. + // Consider using a safer alternative in production environments. + // eslint-disable-next-line no-control-regex + const px = /^\$(?:\[(?:\d+|"(?:[^\\"\u0000-\u001f]|\\(?:[\\"\/bfnrt]|u[0-9a-zA-Z]{4}))*")\])*$/; + + (function rez(value: any): void { + // The rez function walks recursively through the object looking for $ref + // properties. When it finds one that has a value that is a path, then it + // replaces the $ref object with a reference to the value that is found by + // the path. + if (value && typeof value === "object") { + if (Array.isArray(value)) { + value.forEach(function (element, i) { + if (typeof element === "object" && element !== null) { + const path = element.$ref; + if (typeof path === "string" && px.test(path)) { + // Security warning: eval is used here + value[i] = eval(path); + } else { + rez(element); + } + } + }); + } else { + Object.keys(value).forEach(function (name) { + const item = value[name]; + if (typeof item === "object" && item !== null) { + const path = item.$ref; + if (typeof path === "string" && px.test(path)) { + // Security warning: eval is used here + value[name] = eval(path); + } else { + rez(item); + } + } + }); + } + } + }($)); + return $; +} \ No newline at end of file diff --git a/src/object/index.ts b/src/object/index.ts index f9ac6c7..6cd0c34 100644 --- a/src/object/index.ts +++ b/src/object/index.ts @@ -1,3 +1,5 @@ export {getNestedValue,setNestedValue} from './nested' export {objectCompare} from './compare' -export {createSelectOptions} from './util' \ No newline at end of file +export {createSelectOptions} from './util' +export {decycle,retrocycle} from './decycle' +export type {RefObject} from './decycle' \ No newline at end of file