-
Notifications
You must be signed in to change notification settings - Fork 14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: Correctly detect truly circular paths in JS objects, rather than objects with multiple references #1862
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -222,99 +222,94 @@ function assignIfUndefined (subject, target) { | |
} | ||
|
||
/** | ||
* Used to find out all the paths which contain a circular reference. | ||
* Used to find out all the paths which contain a circular reference. | ||
* Inspired by https://github.com/douglascrockford/JSON-js/blob/master/cycle.js | ||
* | ||
* @param {*} rootObject The object we want to search within for circular references | ||
* @returns {string[]} Returns an array of strings, the strings are the full paths to the circular references within the rootObject | ||
* @param {*} object The object we want to stringify, and search within for circular references | ||
* @returns {string} The safely stringified JSON string | ||
* @throws {Error} Error with message highlighting the paths which are circular references | ||
*/ | ||
function findCircularPathsIn(rootObject) { | ||
const traversedValues = new WeakSet(); | ||
function safelyStringifyJson(object) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: This function doesn't quite do this - should we change the name? Right now it does three things:
So really the only reason for (2) is so that (3) can report the circular reference paths. And it also means this function is not safely stringifying the json - instead it's designed to error. I think I'd be in favour of actually safely stringifying the data! I don't think we need to go as far as using the JSON schema's support, as it seems unlikely that we'd ever have a server try to reconstruct the original data. Instead I think we could replace it with a human-readable path reference, so the tracking data isn't lost; potentially also paired with a console.error - or perhaps instead an actual thrown error in a timeout, so that global error handlers can also catch/report? |
||
const traversedValues = new WeakMap(); | ||
const circularPaths = []; | ||
|
||
function _findCircularPathsIn(currentObject, path) { | ||
// If we already saw this object | ||
// the rootObject contains a circular reference | ||
// and we can stop looking any further into this currentObj | ||
if (traversedValues.has(currentObject)) { | ||
circularPaths.push(path); | ||
return; | ||
} | ||
// By definition, JSON.stringify() will throw a TypeError | ||
// if the object contains a circular reference | ||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#exceptions | ||
try { | ||
return JSON.stringify(object); | ||
} catch (error) { | ||
// ignore error | ||
} | ||
|
||
// Only Objects and things which inherit from Objects can contain circular references | ||
// I.E. string/number/boolean/template literals can not contain circular references | ||
if (currentObject instanceof Object) { | ||
traversedValues.add(currentObject); | ||
|
||
// Loop through all the values of the current object and search those for circular references | ||
for (const [key, value] of Object.entries(currentObject)) { | ||
// No need to recurse on every value because only things which inherit | ||
// from Objects can contain circular references | ||
if (value instanceof Object) { | ||
const parentObjectIsAnArray = Array.isArray(currentObject); | ||
if (parentObjectIsAnArray) { | ||
// Store path in bracket notation when value is an array | ||
_findCircularPathsIn(value, `${path}[${key}]`); | ||
} else { | ||
// Store path in dot-notation when value is an object | ||
_findCircularPathsIn(value, `${path}.${key}`); | ||
} | ||
} | ||
function findCircularReferences(key, value, path = "") { | ||
let oldPath; | ||
let modifiedValue; | ||
|
||
if ( | ||
typeof value === "object" | ||
&& value !== null | ||
&& !(value instanceof Boolean) | ||
&& !(value instanceof Date) | ||
&& !(value instanceof Number) | ||
&& !(value instanceof RegExp) | ||
&& !(value instanceof String) | ||
) { | ||
oldPath = traversedValues.get(value); | ||
|
||
if (oldPath !== undefined) { | ||
Comment on lines
+258
to
+260
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thought: much like the old function, I think this isn't actually detecting only circular references, is it? It's detecting object values seen before, by putting every object as a key in a weakmap and then using references if those objects are encountered again. Your use of json.stringify() in a try/catch is hiding this from the tests I think 😁 (That said, I think the new function is a significant improvement over the old one because it has much better skipping of built-in types including null. But I think if we're relying on a list of circular references in the error, it won't be...) |
||
circularPaths.push(path); | ||
|
||
// $ref has no standardised meaning | ||
// (see https://json-schema.org/understanding-json-schema/structuring#dollarref), | ||
// but could be used to reconstruct circular references on the server if required | ||
// (see the 'retrocycle' function at | ||
// https://github.com/douglascrockford/JSON-js/blob/master/cycle.js. | ||
return { $ref: oldPath }; | ||
georgecrawford marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
} | ||
|
||
_findCircularPathsIn(rootObject, ""); | ||
return circularPaths; | ||
} | ||
traversedValues.set(value, path); | ||
|
||
if (Array.isArray(value)) { | ||
modifiedValue = []; | ||
value.forEach(function (element, i) { | ||
modifiedValue[i] = findCircularReferences(i, element, path + "[" + i + "]"); | ||
}); | ||
} else { | ||
modifiedValue = {}; | ||
Object.keys(value).forEach(function (name) { | ||
modifiedValue[name] = findCircularReferences( | ||
name, | ||
value[name], | ||
path + "." + name | ||
); | ||
}); | ||
} | ||
|
||
/** | ||
* Used to find out whether an object contains a circular reference. | ||
* | ||
* @param {object} rootObject The object we want to search within for circular references | ||
* @returns {boolean} Returns true if a circular reference was found, otherwise returns false | ||
*/ | ||
function containsCircularPaths(rootObject) { | ||
// Used to keep track of all the values the rootObject contains | ||
const traversedValues = new WeakSet(); | ||
|
||
/** | ||
* | ||
* @param {*} currentObject The current object we want to search within for circular references | ||
* @returns {boolean|undefined} Returns true if a circular reference was found, otherwise returns undefined | ||
*/ | ||
function _containsCircularPaths(currentObject) { | ||
// If we already saw this object | ||
// the rootObject contains a circular reference | ||
// and we can stop looking any further | ||
if (traversedValues.has(currentObject)) { | ||
return true; | ||
return modifiedValue; | ||
} | ||
|
||
// Only Objects and things which inherit from Objects can contain circular references | ||
// I.E. string/number/boolean/template literals can not contain circular references | ||
if (currentObject instanceof Object) { | ||
traversedValues.add(currentObject); | ||
// Loop through all the values of the current object and search those for circular references | ||
for (const value of Object.values(currentObject)) { | ||
// No need to recurse on every value because only things which inherit | ||
// from Objects can contain circular references | ||
if (value instanceof Object) { | ||
if (_containsCircularPaths(value)) { | ||
return true; | ||
} | ||
} | ||
} | ||
} | ||
return value; | ||
} | ||
|
||
// _containsCircularPaths returns true or undefined. | ||
// By using Boolean we convert the undefined into false. | ||
return Boolean( | ||
_containsCircularPaths( | ||
rootObject | ||
) | ||
); | ||
} | ||
const safeJson = JSON.stringify(object, findCircularReferences); | ||
|
||
// NB: by throwing an error when a circular path is encountered, the tracking payload is | ||
// discarded. A future iteration of this feature could be to send the JSON with circular | ||
// references replaced with `$ref: `, and to allow the server to reconstruct the | ||
// references and process the payload. | ||
// See https://json-schema.org/understanding-json-schema/structuring#dollarref, and | ||
// the 'retrocycle' function at https://github.com/douglascrockford/JSON-js/blob/master/cycle.js. | ||
if (circularPaths.length) { | ||
const errorMessage = "AssertionError: o-tracking does not support circular references in the analytics data.\n" + | ||
"Please remove the circular references in the data.\n" + | ||
"Here are the paths in the data which are circular:\n" + | ||
JSON.stringify(circularPaths, undefined, 4); | ||
throw new Error(errorMessage); | ||
} | ||
|
||
return safeJson; | ||
}; | ||
|
||
/** | ||
* Find out whether two objects are deeply equal to each other. | ||
|
@@ -403,7 +398,6 @@ export { | |
sanitise, | ||
assignIfUndefined, | ||
filterProperties, | ||
findCircularPathsIn, | ||
containsCircularPaths, | ||
safelyStringifyJson, | ||
isDeepEqual | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: I think this particular line now only applies to the contained function ;)