diff --git a/src/extended_json.ts b/src/extended_json.ts index af3165f3..07adfbba 100644 --- a/src/extended_json.ts +++ b/src/extended_json.ts @@ -140,9 +140,20 @@ function deserializeValue(value: any, options: EJSON.Options = {}) { return value; } +type EJSONSerializeOptions = EJSON.Options & { + seenObjects: { obj: unknown; propertyName: string }[]; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -function serializeArray(array: any[], options: EJSON.Options): any[] { - return array.map((v: unknown) => serializeValue(v, options)); +function serializeArray(array: any[], options: EJSONSerializeOptions): any[] { + return array.map((v: unknown, index: number) => { + options.seenObjects.push({ propertyName: `index ${index}`, obj: null }); + try { + return serializeValue(v, options); + } finally { + options.seenObjects.pop(); + } + }); } function getISOString(date: Date) { @@ -152,7 +163,37 @@ function getISOString(date: Date) { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function serializeValue(value: any, options: EJSON.Options): any { +function serializeValue(value: any, options: EJSONSerializeOptions): any { + if ((typeof value === 'object' || typeof value === 'function') && value !== null) { + const index = options.seenObjects.findIndex(entry => entry.obj === value); + if (index !== -1) { + const props = options.seenObjects.map(entry => entry.propertyName); + const leadingPart = props + .slice(0, index) + .map(prop => `${prop} -> `) + .join(''); + const alreadySeen = props[index]; + const circularPart = + ' -> ' + + props + .slice(index + 1, props.length - 1) + .map(prop => `${prop} -> `) + .join(''); + const current = props[props.length - 1]; + const leadingSpace = ' '.repeat(leadingPart.length + alreadySeen.length / 2); + const dashes = '-'.repeat( + circularPart.length + (alreadySeen.length + current.length) / 2 - 1 + ); + + throw new TypeError( + 'Converting circular structure to EJSON:\n' + + ` ${leadingPart}${alreadySeen}${circularPart}${current}\n` + + ` ${leadingSpace}\\${dashes}/` + ); + } + options.seenObjects[options.seenObjects.length - 1].obj = value; + } + if (Array.isArray(value)) return serializeArray(value, options); if (value === undefined) return null; @@ -232,7 +273,7 @@ const BSON_TYPE_MAPPINGS = { } as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any -function serializeDocument(doc: any, options: EJSON.Options) { +function serializeDocument(doc: any, options: EJSONSerializeOptions) { if (doc == null || typeof doc !== 'object') throw new Error('not an object instance'); const bsontype: BSONType['_bsontype'] = doc._bsontype; @@ -240,7 +281,12 @@ function serializeDocument(doc: any, options: EJSON.Options) { // It's a regular object. Recursively serialize its property values. const _doc: Document = {}; for (const name in doc) { - _doc[name] = serializeValue(doc[name], options); + options.seenObjects.push({ propertyName: name, obj: null }); + try { + _doc[name] = serializeValue(doc[name], options); + } finally { + options.seenObjects.pop(); + } } return _doc; } else if (isBSONType(doc)) { @@ -365,9 +411,11 @@ export namespace EJSON { replacer = undefined; space = 0; } - options = Object.assign({}, { relaxed: true, legacy: false }, options); + const serializeOptions = Object.assign({ relaxed: true, legacy: false }, options, { + seenObjects: [{ propertyName: '(root)', obj: null }] + }); - const doc = serializeValue(value, options); + const doc = serializeValue(value, serializeOptions); return JSON.stringify(doc, replacer as Parameters[1], space); } diff --git a/test/node/extended_json_tests.js b/test/node/extended_json_tests.js index 5a8125ef..c47d6fb5 100644 --- a/test/node/extended_json_tests.js +++ b/test/node/extended_json_tests.js @@ -500,6 +500,50 @@ describe('Extended JSON', function () { // expect(() => EJSON.serialize(badMap)).to.throw(); // uncomment when EJSON supports ES6 Map }); + context('circular references', () => { + it('should throw a helpful error message for input with circular references', function () { + const obj = { + some: { + property: { + array: [] + } + } + }; + obj.some.property.array.push(obj.some); + expect(() => EJSON.serialize(obj)).to.throw(`\ +Converting circular structure to EJSON: + (root) -> some -> property -> array -> index 0 + \\-----------------------------/`); + }); + + it('should throw a helpful error message for input with circular references, one-level nested', function () { + const obj = {}; + obj.obj = obj; + expect(() => EJSON.serialize(obj)).to.throw(`\ +Converting circular structure to EJSON: + (root) -> obj + \\-------/`); + }); + + it('should throw a helpful error message for input with circular references, one-level nested inside base object', function () { + const obj = {}; + obj.obj = obj; + expect(() => EJSON.serialize({ foo: obj })).to.throw(`\ +Converting circular structure to EJSON: + (root) -> foo -> obj + \\------/`); + }); + + it('should throw a helpful error message for input with circular references, pointing back to base object', function () { + const obj = { foo: {} }; + obj.foo.obj = obj; + expect(() => EJSON.serialize(obj)).to.throw(`\ +Converting circular structure to EJSON: + (root) -> foo -> obj + \\--------------/`); + }); + }); + context('when dealing with legacy extended json', function () { describe('.stringify', function () { context('when serializing binary', function () {