diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b868b3d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,19 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": true, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": true, + "overrides": [ + { + "files": ["*.yml"], + "options": { + "useTabs": false + } + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9f60c1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Javi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e94c72 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +## Summary + +Retrieve a duplicate of the given object, eliminating any circular references. + +This function replaces circular references with a specified default value when the number of circular references exceeds a defined depth limit. Both the default value and depth limit are customizable and optional. + +If no default value is specified, an empty object or array will be used. +If no depth limit is specified, the function will replace circular references at the first occurrence with a depth limit of 0. + +> CommonJS & ESM supported + +## Installation + +> npm i replace-circular-object + +## Usage + +```js +import replaceCircularObject from 'replace-circular-object'; + +const circularObject = {}; +circularObject.property = circularObject; + +const replacedObject = replaceCircularObject(circularObject, 'Default Value!', 3); + +console.log(replacedObject); // { property: { property: { property: 'Default Value!' } } } +``` + +More examples at [test/test.js](test/test.js) diff --git a/index.cjs b/index.cjs new file mode 100644 index 0000000..6ab3552 --- /dev/null +++ b/index.cjs @@ -0,0 +1,92 @@ +'use strict'; + +class CircularReferenceReplacer { + /** + * @param {*} defaultValue - The default value to use for replacing circular references. + * @param {number} depthLimit - The maximum number of circular references allowed before using the default value. + */ + constructor(defaultValue, depthLimit) { + this._depthLimit = depthLimit; + this._defaultValue = defaultValue; + } + + replace(obj) { + return this._replaceRecursively(obj, []); + } + + _replaceRecursively(obj, ancestors) { + if (!this._isJSONObjectOrArray(obj)) { + return obj; + } + if (this._hasCircularReferences(obj, ancestors) && this._exceedsDepthLimit(obj, ancestors)) { + return this._getDefaultValue(obj); + } + return this._doReplace(obj, ancestors); + } + + _doReplace(obj, ancestors) { + ancestors.push(obj); // push the original reference + obj = Array.isArray(obj) ? [...obj] : { ...obj }; // copy + for (const key of Object.keys(obj)) { + obj[key] = this._replaceRecursively( + obj[key], + [...ancestors] // copy of ancestors avoiding mutation by siblings + ); + } + return obj; + } + + _hasCircularReferences(obj, ancestors) { + const hasCircularReferences = ancestors.includes(obj); + return hasCircularReferences; + } + + _exceedsDepthLimit(obj, ancestors) { + const exceedsCircularReferencesLimit = this._countOccurrences(obj, ancestors) >= this._depthLimit; + return exceedsCircularReferencesLimit; + } + + _countOccurrences(value, array) { + const count = array.reduce( + (accumulator, currentValue) => (currentValue === value ? accumulator + 1 : accumulator), + 0 + ); + return count; + } + + _isJSONObjectOrArray(obj) { + return ( + typeof obj === 'object' && + obj !== null && + !(obj instanceof Boolean) && + !(obj instanceof Date) && + !(obj instanceof Number) && + !(obj instanceof RegExp) && + !(obj instanceof String) + ); + } + + _getDefaultValue(obj) { + if (this._defaultValue === undefined) { + return Array.isArray(obj) ? [] : {}; + } + return this._defaultValue; + } +} + +/** + * Retrieve a duplicate of the given object, eliminating any circular references. + * Replaces circular references with a default value if the number of circular references exceeds the depth limit. + * @function + * @param {*} obj - The object to replace circular references in. + * @param {*} [defaultValue] - The default value to use for replacing circular references. If no one is provided, the default value is the empty object or array. + * @param {number} [depthLimit = 0] - The maximum number of circular references allowed before using the default value. + * @returns {*} - The object without circular references. + */ +function replaceCircularObject(obj, defaultValue, depthLimit = 0) { + const circularReferenceReplacer = new CircularReferenceReplacer(defaultValue, depthLimit); + const objWithoutCircularReferences = circularReferenceReplacer.replace(obj); + return objWithoutCircularReferences; +} + +module.exports = replaceCircularObject; diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..5775c41 --- /dev/null +++ b/index.mjs @@ -0,0 +1,92 @@ +'use strict'; + +class CircularReferenceReplacer { + /** + * @param {*} defaultValue - The default value to use for replacing circular references. + * @param {number} depthLimit - The maximum number of circular references allowed before using the default value. + */ + constructor(defaultValue, depthLimit) { + this._depthLimit = depthLimit; + this._defaultValue = defaultValue; + } + + replace(obj) { + return this._replaceRecursively(obj, []); + } + + _replaceRecursively(obj, ancestors) { + if (!this._isJSONObjectOrArray(obj)) { + return obj; + } + if (this._hasCircularReferences(obj, ancestors) && this._exceedsDepthLimit(obj, ancestors)) { + return this._getDefaultValue(obj); + } + return this._doReplace(obj, ancestors); + } + + _doReplace(obj, ancestors) { + ancestors.push(obj); // push the original reference + obj = Array.isArray(obj) ? [...obj] : { ...obj }; // copy + for (const key of Object.keys(obj)) { + obj[key] = this._replaceRecursively( + obj[key], + [...ancestors] // copy of ancestors avoiding mutation by siblings + ); + } + return obj; + } + + _hasCircularReferences(obj, ancestors) { + const hasCircularReferences = ancestors.includes(obj); + return hasCircularReferences; + } + + _exceedsDepthLimit(obj, ancestors) { + const exceedsCircularReferencesLimit = this._countOccurrences(obj, ancestors) >= this._depthLimit; + return exceedsCircularReferencesLimit; + } + + _countOccurrences(value, array) { + const count = array.reduce( + (accumulator, currentValue) => (currentValue === value ? accumulator + 1 : accumulator), + 0 + ); + return count; + } + + _isJSONObjectOrArray(obj) { + return ( + typeof obj === 'object' && + obj !== null && + !(obj instanceof Boolean) && + !(obj instanceof Date) && + !(obj instanceof Number) && + !(obj instanceof RegExp) && + !(obj instanceof String) + ); + } + + _getDefaultValue(obj) { + if (this._defaultValue === undefined) { + return Array.isArray(obj) ? [] : {}; + } + return this._defaultValue; + } +} + +/** + * Retrieve a duplicate of the given object, eliminating any circular references. + * Replaces circular references with a default value if the number of circular references exceeds the depth limit. + * @function + * @param {*} obj - The object to replace circular references in. + * @param {*} [defaultValue] - The default value to use for replacing circular references. If no one is provided, the default value is the empty object or array. + * @param {number} [depthLimit = 0] - The maximum number of circular references allowed before using the default value. + * @returns {*} - The object without circular references. + */ +function replaceCircularObject(obj, defaultValue, depthLimit = 0) { + const circularReferenceReplacer = new CircularReferenceReplacer(defaultValue, depthLimit); + const objWithoutCircularReferences = circularReferenceReplacer.replace(obj); + return objWithoutCircularReferences; +} + +export default replaceCircularObject; diff --git a/package.json b/package.json new file mode 100644 index 0000000..cff67c9 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "replace-circular-object", + "version": "1.0.0", + "description": "Retrieve a duplicate of the given object, eliminating any circular references.", + "main": "index.mjs", + "types": "types/index.d.ts", + "type": "module", + "exports": { + "import": "./index.mjs", + "require": "./index.cjs" + }, + "repository": { + "type": "git", + "url": "https://github.com/javier-sierra-sngular/replace-circular-object" + }, + "keywords": [ + "circular" + ], + "author": "Javi", + "license": "MIT", + "scripts": { + "test": "node test/test.js" + } +} diff --git a/test/test.js b/test/test.js new file mode 100644 index 0000000..41fbc9b --- /dev/null +++ b/test/test.js @@ -0,0 +1,22 @@ +import replaceCircularObject from '../index.cjs'; +import assert from 'node:assert'; + +const circularObject = {}; +circularObject.property = circularObject; + +const circularArray = []; +circularArray.push(circularArray); + +const replacedObjectNoDefault = replaceCircularObject(circularObject); +assert.deepEqual(replacedObjectNoDefault, { property: {} }); + +const replacedObjectWithDefault = replaceCircularObject(circularObject, 'Default Value!', 3); +assert.deepEqual(replacedObjectWithDefault, { property: { property: { property: 'Default Value!' } } }); + +const replacedArray = replaceCircularObject(circularArray); +assert.deepEqual(replacedArray, [[]]); + +const replacedArrayWithDepth = replaceCircularObject(circularArray, undefined, 5); +assert.deepEqual(replacedArrayWithDepth, [[[[[[]]]]]]); + +console.log('🚀🚀🚀🚀🚀 Tests passed! 🚀🚀🚀🚀🚀'); diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..77c95f7 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,11 @@ +/** + * Retrieve a duplicate of the given object, eliminating any circular references. + * Replaces circular references with a default value if the number of circular references exceeds the depth limit. + * @function + * @param {*} obj - The object to replace circular references in. + * @param {*} [defaultValue] - The default value to use for replacing circular references. If no one is provided, the default value is the empty object or array. + * @param {number} [depthLimit = 0] - The maximum number of circular references allowed before using the default value. + * @returns {*} - The object without circular references. + */ +declare function replaceCircularObject(obj: Object, defaultValue?: any, depthLimit?: number): Object; +export default replaceCircularObject;