Skip to content

Commit

Permalink
feat: make circular input errors for EJSON expressive (#433)
Browse files Browse the repository at this point in the history
* feat: make circular input errors for EJSON expressive

Give errors roughly in the style of those thrown by
`JSON.stringify()` on modern Node.js/V8 versions, where the path to
the property and its circularity are visualized instead of just
recursive indefinitely.

This is just one suggested solution – it would be nice to have *some*
kind of better error in these cases, and I think actually displaying
the path would be nice in terms of UX, but I can also see an argument
for avoiding the extra bits of complexity here.

NODE-3226

Co-authored-by: Neal Beeken <neal.beeken@mongodb.com>
  • Loading branch information
addaleax and nbbeeken authored May 10, 2021
1 parent 3a2eff1 commit 7b351cc
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 7 deletions.
62 changes: 55 additions & 7 deletions src/extended_json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -232,15 +273,20 @@ 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;
if (typeof bsontype === 'undefined') {
// 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)) {
Expand Down Expand Up @@ -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<JSON['stringify']>[1], space);
}

Expand Down
44 changes: 44 additions & 0 deletions test/node/extended_json_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down

0 comments on commit 7b351cc

Please sign in to comment.