Source: util/sorted-stringify.ts

  1. /**
  2. * @author Michael Hasenstein <hasenstein@yahoo.com>
  3. * @copyright REFINIO GmbH 2017
  4. * @license CC-BY-NC-SA-2.5; portions MIT License
  5. * @version 0.0.1
  6. */
  7. /* eslint-disable @typescript-eslint/no-unsafe-call */
  8. /**
  9. * @module
  10. */
  11. import {createError} from '../errors';
  12. import type {AnyFunction} from './function';
  13. import type {AnyObject} from './object';
  14. import {getObjTypeName, isFunction, isObject, isString, isSymbol} from './type-checks-basic';
  15. /**
  16. * Internal JSON string build function for (pure) objects and for Error objects (common code
  17. * except for the way to find the object keys, Object.keys() vs. Reflect.ownKeys())
  18. * @private
  19. * @param {object} obj
  20. * @param {string[]} keys - MUTATED (sorted in place)
  21. * @param {Map<*,null|string>} seenObjects - Here it is never `null` because this function only is
  22. * called for objects, and when the thing to stringify is an object there always is a `Set` on
  23. * this parameter in the parent function `actualStringify`
  24. * @param {boolean} convertUnconvertible - When `true` circles, promises, functions and symbols
  25. * are meta-encoded instead of reported with an error
  26. * @param {string} property - When called for a nested sub-object this is the property name of
  27. * the parent object. It is `null` when called for the root object.
  28. * @returns {string}
  29. */
  30. function buildObjString(
  31. obj: Readonly<AnyObject>,
  32. keys: string[],
  33. seenObjects: Map<unknown, null | string>,
  34. convertUnconvertible: boolean,
  35. property: null | string
  36. ): string {
  37. // Leads to predictable insertion-order independent iteration sequence
  38. keys.sort();
  39. let jsonStr = '{';
  40. for (const key of keys) {
  41. // Compatibility with JSON.stringify: object properties that are undefined or point to a
  42. // symbol are excluded
  43. if (obj[key] !== undefined && (!isSymbol(obj[key]) || convertUnconvertible)) {
  44. // If a property is undefined it is skipped, so checking what we added to the JSON
  45. // string thus far is a better option than the others that I thought of, including
  46. // building an array and then calling join(',') on it (wasted memory allocation).
  47. if (!jsonStr.endsWith('{') && !jsonStr.endsWith(',')) {
  48. jsonStr += ',';
  49. }
  50. jsonStr +=
  51. '"' +
  52. key +
  53. '":' +
  54. actualStringify(
  55. obj[key],
  56. convertUnconvertible,
  57. // BRANCHING: Each item creates a different branch and therefore needs a
  58. // copy, otherwise the branches would detect objects in another branch which
  59. // are not a circle.
  60. // Optimization: The (expensive) clone operation is only needed for objects.
  61. isObject(obj[key]) ? new Map(seenObjects) : null,
  62. property === null ? key : `${property}.${key}`
  63. );
  64. }
  65. }
  66. jsonStr += '}';
  67. return jsonStr;
  68. }
  69. // Alternative typing:
  70. // function actualStringify<T extends unknown> (
  71. // obj: T,
  72. // seenObjects: T extends AnyObject ? Map<unknown,null|string> : null
  73. // ): string {
  74. /**
  75. * Inner stringify function: The "seenObjects" parameter is not visible in the public function.
  76. * @private
  77. * @param {*} obj - A value or an object.
  78. * @param {boolean} convertUnconvertible - When `true` circles, promises, functions and symbols
  79. * are meta-encoded instead of reported with an error
  80. * @param {Map<*,null|string>|null} seenObjects - This is set to `null` when the `obj` parameter
  81. * is not an object, otherwise it is a Set
  82. * @param {null|string} property - When called for a nested sub-object this is the property name of
  83. * the parent object. It is `null` when called for the root object.
  84. * @param {boolean} [unorderedArray=false] - This parameter is used during recursion for Set and
  85. * Map objects: Those objects are turned into arrays, for which the function is then called
  86. * recursively. Since Set and Map objects are unordered but are iterated over in insertion order
  87. * we could end up with differently ordered arrays and therefore with different JSON strings.
  88. * That is why if we get an array from such a recursive call we need to order it. The best way
  89. * to do this is to order the JSON strings of the elements. That is why the array cannot be
  90. * pre-ordered after converting Set and Map to their respective array presentations - it would
  91. * not work for most object elements. We can only reliably order all kinds of Set and Map array
  92. * representations if we order the JSON strings of their elements. *Regular arrays* in the
  93. * object itself must of course not be ordered.
  94. * @returns {string} Returns a JSON string
  95. */
  96. function actualStringify(
  97. obj: unknown,
  98. convertUnconvertible: boolean,
  99. seenObjects: null | Map<unknown, null | string>,
  100. property: null | string,
  101. unorderedArray: boolean = false
  102. ): string {
  103. // Circle detection - Like the native stringify function we do not handle circles, but we want
  104. // to detect them early and not through a stack overflow.
  105. // "null" is used as part of an optimization: When the value is an object (or array) we will
  106. // need to clone seenObjects for very sub-item (if it is an object). If it is a simple value it
  107. // is not used at all and cloning the Set would be useless. So if seenObjects is null there
  108. // already was an isObject check before the recursive call to this function.
  109. if (seenObjects !== null) {
  110. const seen = seenObjects.get(obj);
  111. // obj is an object and not a simple value, and it could lead to a circle through its
  112. // sub-items (array items or object properties can be objects we already encountered)
  113. if (seen !== undefined) {
  114. if (convertUnconvertible) {
  115. return `"$$CIRCLE:${seen === null ? '/' : seen}$$"`;
  116. } else {
  117. throw createError('USS-STR1', {property});
  118. }
  119. }
  120. seenObjects.set(obj, property);
  121. }
  122. const objName = getObjTypeName(obj);
  123. switch (objName) {
  124. case 'Array': {
  125. const stringifiedItems = [];
  126. // WE CANNOT USE Array.prototype.map: map() skips over undefined array items, but
  127. // JSON.stringify of an array with holes produces "null" for each hole. We MUST use
  128. // a loop that does not skip undefined array items.
  129. for (const item of obj as any[]) {
  130. // Each call needs an independent copy of "seenObjects"
  131. stringifiedItems.push(
  132. actualStringify(
  133. item,
  134. convertUnconvertible,
  135. // BRANCHING: Each item creates a different branch and therefore needs a
  136. // copy, otherwise the branches would detect objects in another branch which
  137. // are not a circle.
  138. // Optimization: The (expensive) clone operation is only needed for objects.
  139. isObject(item) ? new Map(seenObjects as Map<unknown, string>) : null,
  140. property
  141. )
  142. );
  143. }
  144. if (unorderedArray) {
  145. // The sort() method sorts the elements of an array IN PLACE and returns the
  146. // sorted array. The default sort order is ascending, built upon converting the
  147. // elements into strings, then comparing their sequences of UTF-16 code units
  148. // values.
  149. stringifiedItems.sort();
  150. }
  151. return '[' + stringifiedItems.join(',') + ']';
  152. }
  153. case 'Object':
  154. // 1. toJSON() does *not* have to return a string, so its return value still has to be
  155. // stringified. However, it can be anything, so we have to call the main stringifier
  156. // can can't call buildObjString in case toJSON() does not return an object.
  157. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior
  158. // 2. Note about circle detection: The object returned by toJSON() will probably be a
  159. // new (but identical) one each time so circle detection would fail, but it was already
  160. // done before we get here using the original object.
  161. return isFunction((obj as AnyObject).toJSON)
  162. ? actualStringify(
  163. (obj as AnyObject).toJSON(),
  164. convertUnconvertible,
  165. seenObjects,
  166. property
  167. )
  168. : buildObjString(
  169. obj as AnyObject,
  170. Reflect.ownKeys(obj as AnyObject).filter(isString),
  171. seenObjects as Map<unknown, null | string>,
  172. convertUnconvertible,
  173. property
  174. );
  175. case 'Error':
  176. // About the "keys" (2nd parameter):
  177. // Make sure "name", "message" and "stack" are in the array because during testing
  178. // Firefox did not have "stack" and the name (e.g. "Error" was missing too; also
  179. // make sure they are in the array of properties only once, that's why we go through
  180. // a Set object conversion and back to an array
  181. return buildObjString(
  182. obj as AnyObject,
  183. [
  184. // Guaranteed to be included on any platform:
  185. 'name',
  186. 'message',
  187. 'stack',
  188. // Any additional Error object properties
  189. ...Reflect.ownKeys(obj as Error).filter(isString)
  190. ].filter((item, index, arr) => arr.indexOf(item) === index),
  191. seenObjects as Map<unknown, null | string>,
  192. convertUnconvertible,
  193. property
  194. );
  195. case 'RegExp':
  196. return JSON.stringify(new RegExp(obj as RegExp).source);
  197. case 'Date':
  198. return '"' + (obj as Date).toJSON() + '"';
  199. case 'Set':
  200. // seenObjects always is a Set when obj is an object (null only for primitive types)
  201. return actualStringify(
  202. Array.from(obj as Set<any>),
  203. convertUnconvertible,
  204. seenObjects as Map<unknown, null | string>,
  205. property,
  206. true
  207. );
  208. case 'Map':
  209. return actualStringify(
  210. Array.from(obj as Map<any, any>),
  211. convertUnconvertible,
  212. seenObjects as Map<unknown, null | string>,
  213. property,
  214. true
  215. );
  216. case 'Null':
  217. case 'Undefined':
  218. // Same as JSON.stringify for compatibility. The special case when stringify() gets
  219. // "undefined" as input is handled in the exported parent function, the case where
  220. // object property values are undefined is handled in buildObjString()
  221. return 'null';
  222. case 'Function':
  223. return '[FUNCTION] ' + (obj as AnyFunction).toString();
  224. // case 'Function':
  225. case 'Promise':
  226. return '';
  227. case 'Symbol': {
  228. if (convertUnconvertible) {
  229. return `"$$$SYMBOL:${String(obj)}$$$"`;
  230. } else {
  231. // Error message parameter: We cannot pass the entire object - createError() will
  232. // call sortedStringify and cause a loop
  233. throw createError('USS-STR4', {obj: objName});
  234. }
  235. }
  236. default:
  237. // All simple types incl. special values such as NaN
  238. return JSON.stringify(obj);
  239. }
  240. }
  241. /**
  242. * A deterministic version of `JSON.stringify` that always creates the exact same string for the
  243. * same data. It also handles Map and Set objects for which the native method returns an empty
  244. * object "{}" by converting them to arrays.
  245. *
  246. * ## Features
  247. *
  248. * - Circle detection: By default and if the 2nd parameter "`convertCircles`" is `false` a
  249. * circular structure results in an error. However, if the parameter is set to `true` circles
  250. * will be converted into meta-information inside the JSON string. This can be used either for
  251. * debug or error output, where a circle should be reported rather than preventing all output,
  252. * or it can be used by a recipient to recreate the circle when reviving an object from the
  253. * JSON string.
  254. * - Determinism is achieved by sorting object keys instead of using the natural Javascript
  255. * iteration sequence determined by property insertion order.
  256. * - Supports everything native `JSON.stringify` does, except that...
  257. * - Like `JSON.stringify`, ES 2015 symbols are not supported, but unlike the standard method ours
  258. * will throw an error when encountering a symbol instead of quietly treating it as `undefined`.
  259. * - In addition stringifies functions (relying on function object's `toString()` method), `Map`
  260. * and `Set` objects, `Error` objects. To recreate the original objects a _reviver_ function
  261. * fill be needed for `JSON.parse` for these non-standard objects.
  262. * - `Map` and `Set` objects are simply represented as arrays, so the reviver function will have
  263. * to know which properties are of those types. This stringifier does not add any meta
  264. * information that a reviver could use to learn about such types. Since the main purpose of
  265. * this function is to stringify values of ONE objects for microdata representation this is
  266. * good enough. The reviver can (and does) use the type information in the ONE object recipes.
  267. * - **Insertion order is lost:** The array representation of `Map` and `Set` will be sorted (each
  268. * array item's string representation is used for this). The keys of objects being stringified
  269. * are sorted as well. This is to solve the problem that the array representation is
  270. * insertion-order dependent even though Map and Set objects are unordered, because iteration
  271. * order of objects in Javascript respects insertion order. **This means that any code relying
  272. * on maintaining the original insertion order will fail!**
  273. * - Just like `JSON.stringify`, only enumerable properties are included.
  274. *
  275. * ## Performance
  276. *
  277. * Testing on node.js 7.10 showed this function takes about twice as long as the native method.
  278. * On IE Edge and on Firefox 53 it took 10 times as long or worse. For comparison:
  279. *
  280. * - Package {@link https://github.com/Kikobeats/json-stringify-deterministic} took over five
  281. * times as long as this code.
  282. * - Package {@link https://github.com/substack/json-stable-stringify} took more than twice as
  283. * long.
  284. *
  285. * See {@link https://abdulapopoola.com/2017/02/27/what-you-didnt-know-about-json-stringify/}
  286. * for information about some idiosyncrasies of JSON conversion in JavaScript.
  287. * @static
  288. * @param {*} obj - A value or an object.
  289. * @returns {string} Returns a JSON string
  290. * @throws {Error} Throws an error if a circle is detected or if a Function, Promise or Symbol
  291. * is detected.
  292. */
  293. export function stringify<T extends void | unknown>(obj: unknown): T extends void ? void : string {
  294. // RETURN TYPE CASTS for the conditional function return type as recommended here:
  295. // https://github.com/Microsoft/TypeScript/issues/22735#issuecomment-374817151
  296. if (obj === undefined) {
  297. return undefined as T extends void ? void : string;
  298. }
  299. // Use an inner function to hide the internal circle detection array parameter and for the
  300. // special undefined value return that is only used for the parent value.
  301. return actualStringify(obj, false, isObject(obj) ? new Map() : null, null) as T extends void
  302. ? void
  303. : string;
  304. }
  305. /**
  306. * Same as {@link stringify}, but when a circle is detected it is meta-encoded in the JSON
  307. * string result instead of throwing an error. Recreating the original object from that JSON
  308. * string will require a special reviver that uses the metadata to recreate the circle.
  309. * The main use case though is when this stringifier is used to create readable string output
  310. * for errors messages or for debugging. In those cases knowing that there is a circle is
  311. * infinitely better than getting another error from inside the original error because some
  312. * object that was meant to be part of the error message cold not be stringified because of a
  313. * circle.
  314. * @static
  315. * @param {*} obj - A value or an object.
  316. * @returns {string} Returns a JSON string
  317. */
  318. export function stringifyWithCircles<T extends void | unknown>(
  319. obj: unknown
  320. ): T extends void ? void : string {
  321. // RETURN TYPE CASTS for the conditional function return type as recommended here:
  322. // https://github.com/Microsoft/TypeScript/issues/22735#issuecomment-374817151
  323. if (obj === undefined) {
  324. return undefined as T extends void ? void : string;
  325. }
  326. // Use an inner function to hide the internal circle detection array parameter and for the
  327. // special undefined value return that is only used for the parent value.
  328. return actualStringify(obj, true, isObject(obj) ? new Map() : null, null) as T extends void
  329. ? void
  330. : string;
  331. }